mirror of
https://gitea.com/actions/checkout.git
synced 2026-07-08 15:31:51 +08:00
Compare commits
2 Commits
3292e202f3
...
releases/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6026fb2ad3 | ||
|
|
93cb6efe18 |
11
.github/workflows/test.yml
vendored
11
.github/workflows/test.yml
vendored
@@ -299,18 +299,15 @@ jobs:
|
|||||||
test-output:
|
test-output:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Download the action at the current ref
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.6
|
uses: actions/checkout@v4.1.6
|
||||||
with:
|
|
||||||
path: actions-checkout
|
|
||||||
|
|
||||||
# Basic checkout using git
|
# Basic checkout using git
|
||||||
- name: Checkout basic
|
- name: Checkout basic
|
||||||
id: checkout
|
id: checkout
|
||||||
uses: ./actions-checkout
|
uses: ./
|
||||||
with:
|
with:
|
||||||
path: cloned-using-local-action
|
|
||||||
ref: test-data/v2/basic
|
ref: test-data/v2/basic
|
||||||
|
|
||||||
# Verify output
|
# Verify output
|
||||||
@@ -328,3 +325,7 @@ jobs:
|
|||||||
echo "Expected commit to be 82f71901cf8c021332310dcc8cdba84c4193ff5d"
|
echo "Expected commit to be 82f71901cf8c021332310dcc8cdba84c4193ff5d"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# needed to make checkout post cleanup succeed
|
||||||
|
- name: Fix Checkout
|
||||||
|
uses: actions/checkout@v4.1.6
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -2,11 +2,7 @@
|
|||||||
|
|
||||||
# Checkout V5
|
# Checkout V5
|
||||||
|
|
||||||
## What's new
|
Checkout v5 now supports Node.js 24
|
||||||
|
|
||||||
- Updated to the node24 runtime
|
|
||||||
- This requires a minimum Actions Runner version of [v2.327.1](https://github.com/actions/runner/releases/tag/v2.327.1) to run.
|
|
||||||
|
|
||||||
|
|
||||||
# Checkout V4
|
# Checkout V4
|
||||||
|
|
||||||
@@ -152,16 +148,24 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
# running from unless specified. Example URLs are https://github.com or
|
# running from unless specified. Example URLs are https://github.com or
|
||||||
# https://my-ghes-server.example.com
|
# https://my-ghes-server.example.com
|
||||||
github-server-url: ''
|
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 -->
|
<!-- end usage -->
|
||||||
|
|
||||||
# Scenarios
|
# Scenarios
|
||||||
|
|
||||||
- [Checkout V5](#checkout-v5)
|
- [Checkout V5](#checkout-v5)
|
||||||
- [What's new](#whats-new)
|
|
||||||
- [Checkout V4](#checkout-v4)
|
- [Checkout V4](#checkout-v4)
|
||||||
- [Note](#note)
|
- [Note](#note)
|
||||||
- [What's new](#whats-new-1)
|
- [What's new](#whats-new)
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
- [Scenarios](#scenarios)
|
- [Scenarios](#scenarios)
|
||||||
- [Fetch only the root files](#fetch-only-the-root-files)
|
- [Fetch only the root files](#fetch-only-the-root-files)
|
||||||
|
|||||||
@@ -86,29 +86,16 @@ describe('git-auth-helper tests', () => {
|
|||||||
// Act
|
// Act
|
||||||
await authHelper.configureAuth()
|
await authHelper.configureAuth()
|
||||||
|
|
||||||
// Assert config - check that .git/config contains includeIf entries
|
// Assert config
|
||||||
const localConfigContent = (
|
const configContent = (
|
||||||
await fs.promises.readFile(localGitConfigPath)
|
await fs.promises.readFile(localGitConfigPath)
|
||||||
).toString()
|
).toString()
|
||||||
expect(
|
|
||||||
localConfigContent.indexOf('includeIf.gitdir:')
|
|
||||||
).toBeGreaterThanOrEqual(0)
|
|
||||||
|
|
||||||
// Assert credentials config file contains the actual credentials
|
|
||||||
const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
|
|
||||||
f => f.startsWith('git-credentials-') && f.endsWith('.config')
|
|
||||||
)
|
|
||||||
expect(credentialsFiles.length).toBe(1)
|
|
||||||
const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
|
|
||||||
const credentialsContent = (
|
|
||||||
await fs.promises.readFile(credentialsConfigPath)
|
|
||||||
).toString()
|
|
||||||
const basicCredential = Buffer.from(
|
const basicCredential = Buffer.from(
|
||||||
`x-access-token:${settings.authToken}`,
|
`x-access-token:${settings.authToken}`,
|
||||||
'utf8'
|
'utf8'
|
||||||
).toString('base64')
|
).toString('base64')
|
||||||
expect(
|
expect(
|
||||||
credentialsContent.indexOf(
|
configContent.indexOf(
|
||||||
`http.${expectedServerUrl}/.extraheader AUTHORIZATION: basic ${basicCredential}`
|
`http.${expectedServerUrl}/.extraheader AUTHORIZATION: basic ${basicCredential}`
|
||||||
)
|
)
|
||||||
).toBeGreaterThanOrEqual(0)
|
).toBeGreaterThanOrEqual(0)
|
||||||
@@ -133,7 +120,7 @@ describe('git-auth-helper tests', () => {
|
|||||||
'inject https://github.com as github server url'
|
'inject https://github.com as github server url'
|
||||||
it(configureAuth_AcceptsGitHubServerUrlSetToGHEC, async () => {
|
it(configureAuth_AcceptsGitHubServerUrlSetToGHEC, async () => {
|
||||||
await testAuthHeader(
|
await testAuthHeader(
|
||||||
configureAuth_AcceptsGitHubServerUrlSetToGHEC,
|
configureAuth_AcceptsGitHubServerUrl,
|
||||||
'https://github.com'
|
'https://github.com'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -154,17 +141,12 @@ describe('git-auth-helper tests', () => {
|
|||||||
// Act
|
// Act
|
||||||
await authHelper.configureAuth()
|
await authHelper.configureAuth()
|
||||||
|
|
||||||
// Assert config - check credentials config file (not local .git/config)
|
// Assert config
|
||||||
const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
|
const configContent = (
|
||||||
f => f.startsWith('git-credentials-') && f.endsWith('.config')
|
await fs.promises.readFile(localGitConfigPath)
|
||||||
)
|
|
||||||
expect(credentialsFiles.length).toBe(1)
|
|
||||||
const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
|
|
||||||
const credentialsContent = (
|
|
||||||
await fs.promises.readFile(credentialsConfigPath)
|
|
||||||
).toString()
|
).toString()
|
||||||
expect(
|
expect(
|
||||||
credentialsContent.indexOf(
|
configContent.indexOf(
|
||||||
`http.https://github.com/.extraheader AUTHORIZATION`
|
`http.https://github.com/.extraheader AUTHORIZATION`
|
||||||
)
|
)
|
||||||
).toBeGreaterThanOrEqual(0)
|
).toBeGreaterThanOrEqual(0)
|
||||||
@@ -269,16 +251,13 @@ describe('git-auth-helper tests', () => {
|
|||||||
expectedSshCommand
|
expectedSshCommand
|
||||||
)
|
)
|
||||||
|
|
||||||
// Assert git config
|
// Asserty git config
|
||||||
const gitConfigLines = (await fs.promises.readFile(localGitConfigPath))
|
const gitConfigLines = (await fs.promises.readFile(localGitConfigPath))
|
||||||
.toString()
|
.toString()
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter(x => x)
|
.filter(x => x)
|
||||||
// Should have includeIf entries pointing to credentials file
|
expect(gitConfigLines).toHaveLength(1)
|
||||||
expect(gitConfigLines.length).toBeGreaterThan(0)
|
expect(gitConfigLines[0]).toMatch(/^http\./)
|
||||||
expect(
|
|
||||||
gitConfigLines.some(line => line.indexOf('includeIf.gitdir:') >= 0)
|
|
||||||
).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const configureAuth_setsSshCommandWhenPersistCredentialsTrue =
|
const configureAuth_setsSshCommandWhenPersistCredentialsTrue =
|
||||||
@@ -440,20 +419,8 @@ describe('git-auth-helper tests', () => {
|
|||||||
expect(
|
expect(
|
||||||
configContent.indexOf('value-from-global-config')
|
configContent.indexOf('value-from-global-config')
|
||||||
).toBeGreaterThanOrEqual(0)
|
).toBeGreaterThanOrEqual(0)
|
||||||
// Global config should have include.path pointing to credentials file
|
|
||||||
expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0)
|
|
||||||
|
|
||||||
// Check credentials in the separate config file
|
|
||||||
const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
|
|
||||||
f => f.startsWith('git-credentials-') && f.endsWith('.config')
|
|
||||||
)
|
|
||||||
expect(credentialsFiles.length).toBeGreaterThan(0)
|
|
||||||
const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
|
|
||||||
const credentialsContent = (
|
|
||||||
await fs.promises.readFile(credentialsConfigPath)
|
|
||||||
).toString()
|
|
||||||
expect(
|
expect(
|
||||||
credentialsContent.indexOf(
|
configContent.indexOf(
|
||||||
`http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
|
`http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
|
||||||
)
|
)
|
||||||
).toBeGreaterThanOrEqual(0)
|
).toBeGreaterThanOrEqual(0)
|
||||||
@@ -496,20 +463,8 @@ describe('git-auth-helper tests', () => {
|
|||||||
const configContent = (
|
const configContent = (
|
||||||
await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig'))
|
await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig'))
|
||||||
).toString()
|
).toString()
|
||||||
// Global config should have include.path pointing to credentials file
|
|
||||||
expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0)
|
|
||||||
|
|
||||||
// Check credentials in the separate config file
|
|
||||||
const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
|
|
||||||
f => f.startsWith('git-credentials-') && f.endsWith('.config')
|
|
||||||
)
|
|
||||||
expect(credentialsFiles.length).toBeGreaterThan(0)
|
|
||||||
const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
|
|
||||||
const credentialsContent = (
|
|
||||||
await fs.promises.readFile(credentialsConfigPath)
|
|
||||||
).toString()
|
|
||||||
expect(
|
expect(
|
||||||
credentialsContent.indexOf(
|
configContent.indexOf(
|
||||||
`http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
|
`http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
|
||||||
)
|
)
|
||||||
).toBeGreaterThanOrEqual(0)
|
).toBeGreaterThanOrEqual(0)
|
||||||
@@ -595,14 +550,11 @@ describe('git-auth-helper tests', () => {
|
|||||||
await authHelper.configureSubmoduleAuth()
|
await authHelper.configureSubmoduleAuth()
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
// Should get submodule config paths (1 call) and configure insteadOf (2 calls for two values)
|
|
||||||
expect(mockSubmoduleForeach).toHaveBeenCalledTimes(4)
|
expect(mockSubmoduleForeach).toHaveBeenCalledTimes(4)
|
||||||
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
|
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
|
||||||
/unset-all.*insteadOf/
|
/unset-all.*insteadOf/
|
||||||
)
|
)
|
||||||
expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(
|
expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/)
|
||||||
/show-origin.*remote\.origin\.url/
|
|
||||||
)
|
|
||||||
expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(
|
expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(
|
||||||
/url.*insteadOf.*git@github.com:/
|
/url.*insteadOf.*git@github.com:/
|
||||||
)
|
)
|
||||||
@@ -637,14 +589,11 @@ describe('git-auth-helper tests', () => {
|
|||||||
await authHelper.configureSubmoduleAuth()
|
await authHelper.configureSubmoduleAuth()
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
// Should get submodule config paths (1 call) and configure sshCommand (1 call)
|
|
||||||
expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3)
|
expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3)
|
||||||
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
|
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
|
||||||
/unset-all.*insteadOf/
|
/unset-all.*insteadOf/
|
||||||
)
|
)
|
||||||
expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(
|
expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/)
|
||||||
/show-origin.*remote\.origin\.url/
|
|
||||||
)
|
|
||||||
expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/core\.sshCommand/)
|
expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/core\.sshCommand/)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -711,35 +660,296 @@ describe('git-auth-helper tests', () => {
|
|||||||
await setup(removeAuth_removesToken)
|
await setup(removeAuth_removesToken)
|
||||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||||
await authHelper.configureAuth()
|
await authHelper.configureAuth()
|
||||||
|
let gitConfigContent = (
|
||||||
// Sanity check - verify includeIf entries exist in local config
|
|
||||||
let localConfigContent = (
|
|
||||||
await fs.promises.readFile(localGitConfigPath)
|
await fs.promises.readFile(localGitConfigPath)
|
||||||
).toString()
|
).toString()
|
||||||
expect(
|
expect(gitConfigContent.indexOf('http.')).toBeGreaterThanOrEqual(0) // sanity check
|
||||||
localConfigContent.indexOf('includeIf.gitdir:')
|
|
||||||
).toBeGreaterThanOrEqual(0)
|
|
||||||
|
|
||||||
// Sanity check - verify credentials file exists
|
// Act
|
||||||
let credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
|
await authHelper.removeAuth()
|
||||||
f => f.startsWith('git-credentials-') && f.endsWith('.config')
|
|
||||||
|
// Assert git config
|
||||||
|
gitConfigContent = (
|
||||||
|
await fs.promises.readFile(localGitConfigPath)
|
||||||
|
).toString()
|
||||||
|
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
|
||||||
|
}
|
||||||
)
|
)
|
||||||
expect(credentialsFiles.length).toBe(1)
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await authHelper.removeAuth()
|
await authHelper.removeAuth()
|
||||||
|
|
||||||
// Assert includeIf entries removed from local git config
|
// Assert includeIf entries removed from local git config
|
||||||
localConfigContent = (
|
gitConfigContent = (
|
||||||
await fs.promises.readFile(localGitConfigPath)
|
await fs.promises.readFile(localGitConfigPath)
|
||||||
).toString()
|
).toString()
|
||||||
expect(localConfigContent.indexOf('includeIf.gitdir:')).toBeLessThan(0)
|
expect(gitConfigContent.indexOf('includeIf')).toBeLessThan(0)
|
||||||
|
expect(gitConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
|
||||||
|
|
||||||
// Assert credentials config file deleted
|
// Assert credentials config file deleted
|
||||||
credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
|
try {
|
||||||
f => f.startsWith('git-credentials-') && f.endsWith('.config')
|
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`
|
||||||
)
|
)
|
||||||
expect(credentialsFiles.length).toBe(0)
|
|
||||||
|
// 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 =
|
const removeGlobalConfig_removesOverride =
|
||||||
@@ -782,7 +992,6 @@ async function setup(testName: string): Promise<void> {
|
|||||||
await fs.promises.mkdir(tempHomedir, {recursive: true})
|
await fs.promises.mkdir(tempHomedir, {recursive: true})
|
||||||
process.env['RUNNER_TEMP'] = runnerTemp
|
process.env['RUNNER_TEMP'] = runnerTemp
|
||||||
process.env['HOME'] = tempHomedir
|
process.env['HOME'] = tempHomedir
|
||||||
process.env['GITHUB_WORKSPACE'] = workspace
|
|
||||||
|
|
||||||
// Create git config
|
// Create git config
|
||||||
globalGitConfigPath = path.join(tempHomedir, '.gitconfig')
|
globalGitConfigPath = path.join(tempHomedir, '.gitconfig')
|
||||||
@@ -801,20 +1010,10 @@ async function setup(testName: string): Promise<void> {
|
|||||||
checkout: jest.fn(),
|
checkout: jest.fn(),
|
||||||
checkoutDetach: jest.fn(),
|
checkoutDetach: jest.fn(),
|
||||||
config: jest.fn(
|
config: jest.fn(
|
||||||
async (
|
async (key: string, value: string, globalConfig?: boolean) => {
|
||||||
key: string,
|
const configPath = globalConfig
|
||||||
value: string,
|
? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
|
||||||
globalConfig?: boolean,
|
: localGitConfigPath
|
||||||
add?: boolean,
|
|
||||||
configFile?: string
|
|
||||||
) => {
|
|
||||||
const configPath =
|
|
||||||
configFile ||
|
|
||||||
(globalConfig
|
|
||||||
? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
|
|
||||||
: localGitConfigPath)
|
|
||||||
// Ensure directory exists
|
|
||||||
await fs.promises.mkdir(path.dirname(configPath), {recursive: true})
|
|
||||||
await fs.promises.appendFile(configPath, `\n${key} ${value}`)
|
await fs.promises.appendFile(configPath, `\n${key} ${value}`)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -872,53 +1071,20 @@ async function setup(testName: string): Promise<void> {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
tryConfigUnsetValue: jest.fn(
|
|
||||||
async (key: string, value: string, globalConfig?: boolean): Promise<boolean> => {
|
|
||||||
const configPath = globalConfig
|
|
||||||
? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
|
|
||||||
: localGitConfigPath
|
|
||||||
let content = await fs.promises.readFile(configPath)
|
|
||||||
let lines = content
|
|
||||||
.toString()
|
|
||||||
.split('\n')
|
|
||||||
.filter(x => x)
|
|
||||||
.filter(x => !(x.startsWith(key) && x.includes(value)))
|
|
||||||
await fs.promises.writeFile(configPath, lines.join('\n'))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
),
|
|
||||||
tryDisableAutomaticGarbageCollection: jest.fn(),
|
tryDisableAutomaticGarbageCollection: jest.fn(),
|
||||||
tryGetFetchUrl: jest.fn(),
|
tryGetFetchUrl: jest.fn(),
|
||||||
tryGetConfigValues: jest.fn(
|
getSubmoduleConfigPaths: jest.fn(async () => {
|
||||||
async (key: string, globalConfig?: boolean): Promise<string[]> => {
|
return []
|
||||||
const configPath = globalConfig
|
}),
|
||||||
? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
|
tryConfigUnsetValue: jest.fn(async () => {
|
||||||
: localGitConfigPath
|
return true
|
||||||
const content = await fs.promises.readFile(configPath)
|
}),
|
||||||
const lines = content
|
tryGetConfigValues: jest.fn(async () => {
|
||||||
.toString()
|
return []
|
||||||
.split('\n')
|
}),
|
||||||
.filter(x => x && x.startsWith(key))
|
tryGetConfigKeys: jest.fn(async () => {
|
||||||
.map(x => x.substring(key.length).trim())
|
return []
|
||||||
return lines
|
}),
|
||||||
}
|
|
||||||
),
|
|
||||||
tryGetConfigKeys: jest.fn(
|
|
||||||
async (pattern: string, globalConfig?: boolean): Promise<string[]> => {
|
|
||||||
const configPath = globalConfig
|
|
||||||
? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
|
|
||||||
: localGitConfigPath
|
|
||||||
const content = await fs.promises.readFile(configPath)
|
|
||||||
const lines = content
|
|
||||||
.toString()
|
|
||||||
.split('\n')
|
|
||||||
.filter(x => x)
|
|
||||||
const keys = lines
|
|
||||||
.filter(x => new RegExp(pattern).test(x.split(' ')[0]))
|
|
||||||
.map(x => x.split(' ')[0])
|
|
||||||
return [...new Set(keys)] // Remove duplicates
|
|
||||||
}
|
|
||||||
),
|
|
||||||
tryReset: jest.fn(),
|
tryReset: jest.fn(),
|
||||||
version: jest.fn()
|
version: jest.fn()
|
||||||
}
|
}
|
||||||
@@ -947,13 +1113,13 @@ async function setup(testName: string): Promise<void> {
|
|||||||
sshUser: '',
|
sshUser: '',
|
||||||
workflowOrganizationId: 123456,
|
workflowOrganizationId: 123456,
|
||||||
setSafeDirectory: true,
|
setSafeDirectory: true,
|
||||||
githubServerUrl: githubServerUrl
|
githubServerUrl: githubServerUrl,
|
||||||
|
allowUnsafePrCheckout: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getActualSshKeyPath(): Promise<string> {
|
async function getActualSshKeyPath(): Promise<string> {
|
||||||
let actualTempFiles = (await fs.promises.readdir(runnerTemp))
|
let actualTempFiles = (await fs.promises.readdir(runnerTemp))
|
||||||
.filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file
|
|
||||||
.sort()
|
.sort()
|
||||||
.map(x => path.join(runnerTemp, x))
|
.map(x => path.join(runnerTemp, x))
|
||||||
if (actualTempFiles.length === 0) {
|
if (actualTempFiles.length === 0) {
|
||||||
@@ -967,7 +1133,6 @@ async function getActualSshKeyPath(): Promise<string> {
|
|||||||
|
|
||||||
async function getActualSshKnownHostsPath(): Promise<string> {
|
async function getActualSshKnownHostsPath(): Promise<string> {
|
||||||
let actualTempFiles = (await fs.promises.readdir(runnerTemp))
|
let actualTempFiles = (await fs.promises.readdir(runnerTemp))
|
||||||
.filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file
|
|
||||||
.sort()
|
.sort()
|
||||||
.map(x => path.join(runnerTemp, x))
|
.map(x => path.join(runnerTemp, x))
|
||||||
if (actualTempFiles.length === 0) {
|
if (actualTempFiles.length === 0) {
|
||||||
|
|||||||
@@ -493,15 +493,24 @@ async function setup(testName: string): Promise<void> {
|
|||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
tryConfigUnset: jest.fn(),
|
tryConfigUnset: jest.fn(),
|
||||||
tryConfigUnsetValue: jest.fn(),
|
|
||||||
tryDisableAutomaticGarbageCollection: jest.fn(),
|
tryDisableAutomaticGarbageCollection: jest.fn(),
|
||||||
tryGetFetchUrl: jest.fn(async () => {
|
tryGetFetchUrl: jest.fn(async () => {
|
||||||
// Sanity check - this function shouldn't be called when the .git directory doesn't exist
|
// Sanity check - this function shouldn't be called when the .git directory doesn't exist
|
||||||
await fs.promises.stat(path.join(repositoryPath, '.git'))
|
await fs.promises.stat(path.join(repositoryPath, '.git'))
|
||||||
return repositoryUrl
|
return repositoryUrl
|
||||||
}),
|
}),
|
||||||
tryGetConfigValues: jest.fn(),
|
getSubmoduleConfigPaths: jest.fn(async () => {
|
||||||
tryGetConfigKeys: jest.fn(),
|
return []
|
||||||
|
}),
|
||||||
|
tryConfigUnsetValue: jest.fn(async () => {
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
tryGetConfigValues: jest.fn(async () => {
|
||||||
|
return []
|
||||||
|
}),
|
||||||
|
tryGetConfigKeys: jest.fn(async () => {
|
||||||
|
return []
|
||||||
|
}),
|
||||||
tryReset: jest.fn(async () => {
|
tryReset: jest.fn(async () => {
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ describe('input-helper tests', () => {
|
|||||||
expect(settings.repositoryOwner).toBe('some-owner')
|
expect(settings.repositoryOwner).toBe('some-owner')
|
||||||
expect(settings.repositoryPath).toBe(gitHubWorkspace)
|
expect(settings.repositoryPath).toBe(gitHubWorkspace)
|
||||||
expect(settings.setSafeDirectory).toBe(true)
|
expect(settings.setSafeDirectory).toBe(true)
|
||||||
|
expect(settings.allowUnsafePrCheckout).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('qualifies ref', async () => {
|
it('qualifies ref', async () => {
|
||||||
|
|||||||
267
__test__/unsafe-pr-checkout-helper.test.ts
Normal file
267
__test__/unsafe-pr-checkout-helper.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -98,6 +98,15 @@ inputs:
|
|||||||
github-server-url:
|
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
|
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
|
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:
|
outputs:
|
||||||
ref:
|
ref:
|
||||||
description: 'The branch, tag or SHA that was checked out'
|
description: 'The branch, tag or SHA that was checked out'
|
||||||
|
|||||||
426
dist/index.js
vendored
426
dist/index.js
vendored
@@ -162,8 +162,6 @@ class GitAuthHelper {
|
|||||||
this.sshKeyPath = '';
|
this.sshKeyPath = '';
|
||||||
this.sshKnownHostsPath = '';
|
this.sshKnownHostsPath = '';
|
||||||
this.temporaryHomePath = '';
|
this.temporaryHomePath = '';
|
||||||
this.credentialsConfigPath = ''; // Path to separate credentials config file in RUNNER_TEMP
|
|
||||||
this.credentialsIncludeKeys = []; // Track includeIf config keys for cleanup
|
|
||||||
this.git = gitCommandManager;
|
this.git = gitCommandManager;
|
||||||
this.settings = gitSourceSettings || {};
|
this.settings = gitSourceSettings || {};
|
||||||
// Token auth header
|
// Token auth header
|
||||||
@@ -189,20 +187,6 @@ class GitAuthHelper {
|
|||||||
yield this.configureToken();
|
yield this.configureToken();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
getCredentialsConfigPath() {
|
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
|
||||||
if (this.credentialsConfigPath) {
|
|
||||||
return this.credentialsConfigPath;
|
|
||||||
}
|
|
||||||
const runnerTemp = process.env['RUNNER_TEMP'] || '';
|
|
||||||
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined');
|
|
||||||
// Create a unique filename for this checkout instance
|
|
||||||
const configFileName = `git-credentials-${(0, uuid_1.v4)()}.config`;
|
|
||||||
this.credentialsConfigPath = path.join(runnerTemp, configFileName);
|
|
||||||
core.debug(`Credentials config path: ${this.credentialsConfigPath}`);
|
|
||||||
return this.credentialsConfigPath;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
configureTempGlobalConfig() {
|
configureTempGlobalConfig() {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
var _a;
|
var _a;
|
||||||
@@ -245,10 +229,10 @@ class GitAuthHelper {
|
|||||||
configureGlobalAuth() {
|
configureGlobalAuth() {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
// 'configureTempGlobalConfig' noops if already set, just returns the path
|
// 'configureTempGlobalConfig' noops if already set, just returns the path
|
||||||
yield this.configureTempGlobalConfig();
|
const newGitConfigPath = yield this.configureTempGlobalConfig();
|
||||||
try {
|
try {
|
||||||
// Configure the token
|
// Configure the token
|
||||||
yield this.configureToken(true);
|
yield this.configureToken(newGitConfigPath, true);
|
||||||
// Configure HTTPS instead of SSH
|
// Configure HTTPS instead of SSH
|
||||||
yield this.git.tryConfigUnset(this.insteadOfKey, true);
|
yield this.git.tryConfigUnset(this.insteadOfKey, true);
|
||||||
if (!this.settings.sshKey) {
|
if (!this.settings.sshKey) {
|
||||||
@@ -268,37 +252,19 @@ class GitAuthHelper {
|
|||||||
configureSubmoduleAuth() {
|
configureSubmoduleAuth() {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
// Remove possible previous HTTPS instead of SSH
|
// Remove possible previous HTTPS instead of SSH
|
||||||
yield this.removeSubmoduleGitConfig(this.insteadOfKey);
|
yield this.removeGitConfig(this.insteadOfKey, true);
|
||||||
if (this.settings.persistCredentials) {
|
if (this.settings.persistCredentials) {
|
||||||
// Credentials config path
|
// Configure a placeholder value. This approach avoids the credential being captured
|
||||||
const credentialsConfigPath = yield this.getCredentialsConfigPath();
|
// by process creation audit events, which are commonly logged. For more information,
|
||||||
// Container credentials config path
|
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
|
||||||
const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath));
|
const output = yield this.git.submoduleForeach(
|
||||||
// Container repo path
|
// wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
|
||||||
const workingDirectory = this.git.getWorkingDirectory();
|
`sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, this.settings.nestedSubmodules);
|
||||||
const githubWorkspace = process.env['GITHUB_WORKSPACE'];
|
// Replace the placeholder
|
||||||
assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined');
|
|
||||||
let relativePath = path.relative(githubWorkspace, workingDirectory);
|
|
||||||
relativePath = relativePath.replace(/\\/g, '/');
|
|
||||||
const containerRepoPath = path.posix.join('/github/workspace', relativePath);
|
|
||||||
// Get submodule config file paths.
|
|
||||||
// Use `--show-origin` to get the config file path for each submodule.
|
|
||||||
const output = yield this.git.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, this.settings.nestedSubmodules);
|
|
||||||
// Extract config file paths from the output (lines starting with "file:").
|
|
||||||
const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
|
const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
|
||||||
// For each submodule, configure includeIf entries pointing to the shared credentials file.
|
|
||||||
// Configure both host and container paths to support Docker container actions.
|
|
||||||
for (const configPath of configPaths) {
|
for (const configPath of configPaths) {
|
||||||
// Submodule Git directory
|
core.debug(`Replacing token placeholder in '${configPath}'`);
|
||||||
let submoduleGitDir = path.dirname(configPath); // The config file is at .git/modules/submodule-name/config
|
yield this.replaceTokenPlaceholder(configPath);
|
||||||
submoduleGitDir = submoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
|
|
||||||
// Configure host includeIf
|
|
||||||
yield this.git.config(`includeIf.gitdir:${submoduleGitDir}.path`, credentialsConfigPath, false, false, configPath);
|
|
||||||
// Configure container includeIf
|
|
||||||
let relativeSubmoduleGitDir = path.relative(githubWorkspace, submoduleGitDir);
|
|
||||||
relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
|
|
||||||
const containerSubmoduleGitDir = path.posix.join('/github/workspace', relativeSubmoduleGitDir);
|
|
||||||
yield this.git.config(`includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, false, configPath);
|
|
||||||
}
|
}
|
||||||
if (this.settings.sshKey) {
|
if (this.settings.sshKey) {
|
||||||
// Configure core.sshCommand
|
// Configure core.sshCommand
|
||||||
@@ -385,44 +351,20 @@ class GitAuthHelper {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
configureToken(globalConfig) {
|
configureToken(configPath, globalConfig) {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
// Get the credentials config file path in RUNNER_TEMP
|
// Validate args
|
||||||
const credentialsConfigPath = yield this.getCredentialsConfigPath();
|
assert.ok((configPath && globalConfig) || (!configPath && !globalConfig), 'Unexpected configureToken parameter combinations');
|
||||||
// Write placeholder to the separate credentials config file using git config.
|
// Default config path
|
||||||
// This approach avoids the credential being captured by process creation audit events,
|
if (!configPath && !globalConfig) {
|
||||||
// which are commonly logged. For more information, refer to
|
configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config');
|
||||||
// https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
|
|
||||||
yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, false, false, credentialsConfigPath);
|
|
||||||
// Replace the placeholder in the credentials config file
|
|
||||||
yield this.replaceTokenPlaceholder(credentialsConfigPath);
|
|
||||||
// Add include or includeIf to reference the credentials config
|
|
||||||
if (globalConfig) {
|
|
||||||
// Global config file is temporary
|
|
||||||
yield this.git.config('include.path', credentialsConfigPath, true);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Host git directory
|
|
||||||
let gitDir = path.join(this.git.getWorkingDirectory(), '.git');
|
|
||||||
gitDir = gitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
|
|
||||||
// Configure host includeIf
|
|
||||||
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`;
|
|
||||||
yield this.git.config(hostIncludeKey, credentialsConfigPath);
|
|
||||||
this.credentialsIncludeKeys.push(hostIncludeKey);
|
|
||||||
// Container git directory
|
|
||||||
const githubWorkspace = process.env['GITHUB_WORKSPACE'];
|
|
||||||
assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined');
|
|
||||||
const workingDirectory = this.git.getWorkingDirectory();
|
|
||||||
let relativePath = path.relative(githubWorkspace, workingDirectory);
|
|
||||||
relativePath = relativePath.replace(/\\/g, '/'); // Use forward slashes, even on Windows
|
|
||||||
const containerGitDir = path.posix.join('/github/workspace', relativePath, '.git');
|
|
||||||
// Container credentials config path
|
|
||||||
const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath));
|
|
||||||
// Configure container includeIf
|
|
||||||
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`;
|
|
||||||
yield this.git.config(containerIncludeKey, containerCredentialsPath);
|
|
||||||
this.credentialsIncludeKeys.push(containerIncludeKey);
|
|
||||||
}
|
}
|
||||||
|
// Configure a placeholder value. This approach avoids the credential being captured
|
||||||
|
// by process creation audit events, which are commonly logged. For more information,
|
||||||
|
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
|
||||||
|
yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, globalConfig);
|
||||||
|
// Replace the placeholder
|
||||||
|
yield this.replaceTokenPlaceholder(configPath || '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
replaceTokenPlaceholder(configPath) {
|
replaceTokenPlaceholder(configPath) {
|
||||||
@@ -465,29 +407,94 @@ class GitAuthHelper {
|
|||||||
}
|
}
|
||||||
// SSH command
|
// SSH command
|
||||||
yield this.removeGitConfig(SSH_COMMAND_KEY);
|
yield this.removeGitConfig(SSH_COMMAND_KEY);
|
||||||
yield this.removeSubmoduleGitConfig(SSH_COMMAND_KEY);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
removeToken() {
|
removeToken() {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
var _a;
|
// Remove HTTP extra header from local git config and submodule configs
|
||||||
// Remove HTTP extra header
|
|
||||||
yield this.removeGitConfig(this.tokenConfigKey);
|
yield this.removeGitConfig(this.tokenConfigKey);
|
||||||
yield this.removeSubmoduleGitConfig(this.tokenConfigKey);
|
//
|
||||||
// Remove includeIf entries that point to git-credentials-*.config files
|
// Cleanup actions/checkout@v6 style credentials
|
||||||
// This is more aggressive than tracking keys, but necessary since cleanup
|
//
|
||||||
// runs in a post-step where this.credentialsIncludeKeys is empty
|
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) {
|
||||||
|
return __awaiter(this, arguments, void 0, function* (configKey, submoduleOnly = false) {
|
||||||
|
if (!submoduleOnly) {
|
||||||
|
if ((yield this.git.configExists(configKey)) &&
|
||||||
|
!(yield this.git.tryConfigUnset(configKey))) {
|
||||||
|
// Load the config contents
|
||||||
|
core.warning(`Failed to remove '${configKey}' from the git config`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pattern = regexpHelper.escape(configKey);
|
||||||
|
yield this.git.submoduleForeach(
|
||||||
|
// wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
|
||||||
|
`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 {
|
try {
|
||||||
// Get all includeIf.gitdir keys
|
// Get all includeIf.gitdir keys
|
||||||
const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:');
|
const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig?
|
||||||
|
configPath);
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
// Get all values for this key
|
// Get all values for this key
|
||||||
const values = yield this.git.tryGetConfigValues(key);
|
const values = yield this.git.tryGetConfigValues(key, false, // globalConfig?
|
||||||
|
configPath);
|
||||||
if (values.length > 0) {
|
if (values.length > 0) {
|
||||||
// Remove only values that match git-credentials-<uuid>.config pattern
|
// Remove only values that match git-credentials-<uuid>.config pattern
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (/git-credentials-[0-9a-f-]+\.config$/i.test(value)) {
|
if (this.testCredentialsConfigPath(value)) {
|
||||||
yield this.git.tryConfigUnsetValue(key, value);
|
credentialsPaths.add(value);
|
||||||
|
yield this.git.tryConfigUnsetValue(key, value, false, configPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,38 +502,18 @@ class GitAuthHelper {
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
// Ignore errors - this is cleanup code
|
// Ignore errors - this is cleanup code
|
||||||
core.debug(`Error during includeIf cleanup: ${err}`);
|
core.debug(`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`);
|
||||||
}
|
|
||||||
// Remove submodule includeIf
|
|
||||||
yield this.git.submoduleForeach(`sh -c "git config --local --get-regexp '^includeif\\.' && git config --local --remove-section includeif || :"`, true);
|
|
||||||
// Remove credentials config file
|
|
||||||
if (this.credentialsConfigPath) {
|
|
||||||
try {
|
|
||||||
yield io.rmRF(this.credentialsConfigPath);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
core.debug(`${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
||||||
core.warning(`Failed to remove credentials config '${this.credentialsConfigPath}'`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return Array.from(credentialsPaths);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
removeGitConfig(configKey) {
|
/**
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
* Tests if a path matches the git-credentials-*.config pattern used by newer versions.
|
||||||
if ((yield this.git.configExists(configKey)) &&
|
* @param path The path to test
|
||||||
!(yield this.git.tryConfigUnset(configKey))) {
|
* @returns True if the path matches the credentials config pattern
|
||||||
// Load the config contents
|
*/
|
||||||
core.warning(`Failed to remove '${configKey}' from the git config`);
|
testCredentialsConfigPath(path) {
|
||||||
}
|
return /git-credentials-[0-9a-f-]+\.config$/i.test(path);
|
||||||
});
|
|
||||||
}
|
|
||||||
removeSubmoduleGitConfig(configKey) {
|
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
|
||||||
const pattern = regexpHelper.escape(configKey);
|
|
||||||
yield this.git.submoduleForeach(
|
|
||||||
// Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline.
|
|
||||||
`sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,15 +712,9 @@ class GitCommandManager {
|
|||||||
yield this.execGit(args);
|
yield this.execGit(args);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
config(configKey, configValue, globalConfig, add, configFile) {
|
config(configKey, configValue, globalConfig, add) {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
const args = ['config'];
|
const args = ['config', globalConfig ? '--global' : '--local'];
|
||||||
if (configFile) {
|
|
||||||
args.push('--file', configFile);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
args.push(globalConfig ? '--global' : '--local');
|
|
||||||
}
|
|
||||||
if (add) {
|
if (add) {
|
||||||
args.push('--add');
|
args.push('--add');
|
||||||
}
|
}
|
||||||
@@ -810,6 +791,16 @@ class GitCommandManager {
|
|||||||
throw new Error('Unexpected output when retrieving default branch');
|
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() {
|
getWorkingDirectory() {
|
||||||
return this.workingDirectory;
|
return this.workingDirectory;
|
||||||
}
|
}
|
||||||
@@ -940,15 +931,17 @@ class GitCommandManager {
|
|||||||
return output.exitCode === 0;
|
return output.exitCode === 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tryConfigUnsetValue(configKey, configValue, globalConfig) {
|
tryConfigUnsetValue(configKey, configValue, globalConfig, configFile) {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
const output = yield this.execGit([
|
const args = ['config'];
|
||||||
'config',
|
if (configFile) {
|
||||||
globalConfig ? '--global' : '--local',
|
args.push('--file', configFile);
|
||||||
'--unset',
|
}
|
||||||
configKey,
|
else {
|
||||||
configValue
|
args.push(globalConfig ? '--global' : '--local');
|
||||||
], true);
|
}
|
||||||
|
args.push('--unset', configKey, configValue);
|
||||||
|
const output = yield this.execGit(args, true);
|
||||||
return output.exitCode === 0;
|
return output.exitCode === 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -971,33 +964,44 @@ class GitCommandManager {
|
|||||||
return stdout;
|
return stdout;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tryGetConfigValues(configKey, globalConfig) {
|
tryGetConfigValues(configKey, globalConfig, configFile) {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
const output = yield this.execGit([
|
const args = ['config'];
|
||||||
'config',
|
if (configFile) {
|
||||||
globalConfig ? '--global' : '--local',
|
args.push('--file', configFile);
|
||||||
'--get-all',
|
}
|
||||||
configKey
|
else {
|
||||||
], true);
|
args.push(globalConfig ? '--global' : '--local');
|
||||||
|
}
|
||||||
|
args.push('--get-all', configKey);
|
||||||
|
const output = yield this.execGit(args, true);
|
||||||
if (output.exitCode !== 0) {
|
if (output.exitCode !== 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return output.stdout.trim().split('\n').filter(value => value.trim());
|
return output.stdout
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(value => value.trim());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tryGetConfigKeys(pattern, globalConfig) {
|
tryGetConfigKeys(pattern, globalConfig, configFile) {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
const output = yield this.execGit([
|
const args = ['config'];
|
||||||
'config',
|
if (configFile) {
|
||||||
globalConfig ? '--global' : '--local',
|
args.push('--file', configFile);
|
||||||
'--name-only',
|
}
|
||||||
'--get-regexp',
|
else {
|
||||||
pattern
|
args.push(globalConfig ? '--global' : '--local');
|
||||||
], true);
|
}
|
||||||
|
args.push('--name-only', '--get-regexp', pattern);
|
||||||
|
const output = yield this.execGit(args, true);
|
||||||
if (output.exitCode !== 0) {
|
if (output.exitCode !== 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return output.stdout.trim().split('\n').filter(key => key.trim());
|
return output.stdout
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(key => key.trim());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tryReset() {
|
tryReset() {
|
||||||
@@ -1855,6 +1859,7 @@ const core = __importStar(__nccwpck_require__(2186));
|
|||||||
const fsHelper = __importStar(__nccwpck_require__(7219));
|
const fsHelper = __importStar(__nccwpck_require__(7219));
|
||||||
const github = __importStar(__nccwpck_require__(5438));
|
const github = __importStar(__nccwpck_require__(5438));
|
||||||
const path = __importStar(__nccwpck_require__(1017));
|
const path = __importStar(__nccwpck_require__(1017));
|
||||||
|
const unsafePrCheckoutHelper = __importStar(__nccwpck_require__(843));
|
||||||
const workflowContextHelper = __importStar(__nccwpck_require__(9568));
|
const workflowContextHelper = __importStar(__nccwpck_require__(9568));
|
||||||
function getInputs() {
|
function getInputs() {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
@@ -1976,6 +1981,17 @@ function getInputs() {
|
|||||||
// Determine the GitHub URL that the repository is being hosted from
|
// Determine the GitHub URL that the repository is being hosted from
|
||||||
result.githubServerUrl = core.getInput('github-server-url');
|
result.githubServerUrl = core.getInput('github-server-url');
|
||||||
core.debug(`GitHub Host URL = ${result.githubServerUrl}`);
|
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;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2116,6 +2132,7 @@ exports.getRefSpecForAllHistory = getRefSpecForAllHistory;
|
|||||||
exports.getRefSpec = getRefSpec;
|
exports.getRefSpec = getRefSpec;
|
||||||
exports.testRef = testRef;
|
exports.testRef = testRef;
|
||||||
exports.checkCommitInfo = checkCommitInfo;
|
exports.checkCommitInfo = checkCommitInfo;
|
||||||
|
exports.fromPayload = fromPayload;
|
||||||
const core = __importStar(__nccwpck_require__(2186));
|
const core = __importStar(__nccwpck_require__(2186));
|
||||||
const github = __importStar(__nccwpck_require__(5438));
|
const github = __importStar(__nccwpck_require__(5438));
|
||||||
const url_helper_1 = __nccwpck_require__(9437);
|
const url_helper_1 = __nccwpck_require__(9437);
|
||||||
@@ -2548,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:
|
/***/ 9437:
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ class GitAuthHelper {
|
|||||||
private sshKeyPath = ''
|
private sshKeyPath = ''
|
||||||
private sshKnownHostsPath = ''
|
private sshKnownHostsPath = ''
|
||||||
private temporaryHomePath = ''
|
private temporaryHomePath = ''
|
||||||
private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP
|
|
||||||
private credentialsIncludeKeys: string[] = [] // Track includeIf config keys for cleanup
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
gitCommandManager: IGitCommandManager,
|
gitCommandManager: IGitCommandManager,
|
||||||
@@ -83,22 +81,6 @@ class GitAuthHelper {
|
|||||||
await this.configureToken()
|
await this.configureToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCredentialsConfigPath(): Promise<string> {
|
|
||||||
if (this.credentialsConfigPath) {
|
|
||||||
return this.credentialsConfigPath
|
|
||||||
}
|
|
||||||
|
|
||||||
const runnerTemp = process.env['RUNNER_TEMP'] || ''
|
|
||||||
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
|
|
||||||
|
|
||||||
// Create a unique filename for this checkout instance
|
|
||||||
const configFileName = `git-credentials-${uuid()}.config`
|
|
||||||
this.credentialsConfigPath = path.join(runnerTemp, configFileName)
|
|
||||||
|
|
||||||
core.debug(`Credentials config path: ${this.credentialsConfigPath}`)
|
|
||||||
return this.credentialsConfigPath
|
|
||||||
}
|
|
||||||
|
|
||||||
async configureTempGlobalConfig(): Promise<string> {
|
async configureTempGlobalConfig(): Promise<string> {
|
||||||
// Already setup global config
|
// Already setup global config
|
||||||
if (this.temporaryHomePath?.length > 0) {
|
if (this.temporaryHomePath?.length > 0) {
|
||||||
@@ -144,10 +126,10 @@ class GitAuthHelper {
|
|||||||
|
|
||||||
async configureGlobalAuth(): Promise<void> {
|
async configureGlobalAuth(): Promise<void> {
|
||||||
// 'configureTempGlobalConfig' noops if already set, just returns the path
|
// 'configureTempGlobalConfig' noops if already set, just returns the path
|
||||||
await this.configureTempGlobalConfig()
|
const newGitConfigPath = await this.configureTempGlobalConfig()
|
||||||
try {
|
try {
|
||||||
// Configure the token
|
// Configure the token
|
||||||
await this.configureToken(true)
|
await this.configureToken(newGitConfigPath, true)
|
||||||
|
|
||||||
// Configure HTTPS instead of SSH
|
// Configure HTTPS instead of SSH
|
||||||
await this.git.tryConfigUnset(this.insteadOfKey, true)
|
await this.git.tryConfigUnset(this.insteadOfKey, true)
|
||||||
@@ -168,73 +150,24 @@ class GitAuthHelper {
|
|||||||
|
|
||||||
async configureSubmoduleAuth(): Promise<void> {
|
async configureSubmoduleAuth(): Promise<void> {
|
||||||
// Remove possible previous HTTPS instead of SSH
|
// Remove possible previous HTTPS instead of SSH
|
||||||
await this.removeSubmoduleGitConfig(this.insteadOfKey)
|
await this.removeGitConfig(this.insteadOfKey, true)
|
||||||
|
|
||||||
if (this.settings.persistCredentials) {
|
if (this.settings.persistCredentials) {
|
||||||
// Credentials config path
|
// Configure a placeholder value. This approach avoids the credential being captured
|
||||||
const credentialsConfigPath = await this.getCredentialsConfigPath()
|
// by process creation audit events, which are commonly logged. For more information,
|
||||||
|
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
|
||||||
// Container credentials config path
|
|
||||||
const containerCredentialsPath = path.posix.join(
|
|
||||||
'/github/runner_temp',
|
|
||||||
path.basename(credentialsConfigPath)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Container repo path
|
|
||||||
const workingDirectory = this.git.getWorkingDirectory()
|
|
||||||
const githubWorkspace = process.env['GITHUB_WORKSPACE']
|
|
||||||
assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
|
|
||||||
let relativePath = path.relative(githubWorkspace, workingDirectory)
|
|
||||||
relativePath = relativePath.replace(/\\/g, '/')
|
|
||||||
const containerRepoPath = path.posix.join(
|
|
||||||
'/github/workspace',
|
|
||||||
relativePath
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get submodule config file paths.
|
|
||||||
// Use `--show-origin` to get the config file path for each submodule.
|
|
||||||
const output = await this.git.submoduleForeach(
|
const output = await this.git.submoduleForeach(
|
||||||
`git config --local --show-origin --name-only --get-regexp remote.origin.url`,
|
// wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
|
||||||
|
`sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`,
|
||||||
this.settings.nestedSubmodules
|
this.settings.nestedSubmodules
|
||||||
)
|
)
|
||||||
|
|
||||||
// Extract config file paths from the output (lines starting with "file:").
|
// Replace the placeholder
|
||||||
const configPaths =
|
const configPaths: string[] =
|
||||||
output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
|
output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
|
||||||
|
|
||||||
// For each submodule, configure includeIf entries pointing to the shared credentials file.
|
|
||||||
// Configure both host and container paths to support Docker container actions.
|
|
||||||
for (const configPath of configPaths) {
|
for (const configPath of configPaths) {
|
||||||
// Submodule Git directory
|
core.debug(`Replacing token placeholder in '${configPath}'`)
|
||||||
let submoduleGitDir = path.dirname(configPath) // The config file is at .git/modules/submodule-name/config
|
await this.replaceTokenPlaceholder(configPath)
|
||||||
submoduleGitDir = submoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
|
|
||||||
|
|
||||||
// Configure host includeIf
|
|
||||||
await this.git.config(
|
|
||||||
`includeIf.gitdir:${submoduleGitDir}.path`,
|
|
||||||
credentialsConfigPath,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
configPath
|
|
||||||
)
|
|
||||||
|
|
||||||
// Configure container includeIf
|
|
||||||
let relativeSubmoduleGitDir = path.relative(
|
|
||||||
githubWorkspace,
|
|
||||||
submoduleGitDir
|
|
||||||
)
|
|
||||||
relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
|
|
||||||
const containerSubmoduleGitDir = path.posix.join(
|
|
||||||
'/github/workspace',
|
|
||||||
relativeSubmoduleGitDir
|
|
||||||
)
|
|
||||||
await this.git.config(
|
|
||||||
`includeIf.gitdir:${containerSubmoduleGitDir}.path`,
|
|
||||||
containerCredentialsPath,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
configPath
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.settings.sshKey) {
|
if (this.settings.sshKey) {
|
||||||
@@ -339,62 +272,32 @@ class GitAuthHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async configureToken(globalConfig?: boolean): Promise<void> {
|
private async configureToken(
|
||||||
// Get the credentials config file path in RUNNER_TEMP
|
configPath?: string,
|
||||||
const credentialsConfigPath = await this.getCredentialsConfigPath()
|
globalConfig?: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
// Validate args
|
||||||
|
assert.ok(
|
||||||
|
(configPath && globalConfig) || (!configPath && !globalConfig),
|
||||||
|
'Unexpected configureToken parameter combinations'
|
||||||
|
)
|
||||||
|
|
||||||
// Write placeholder to the separate credentials config file using git config.
|
// Default config path
|
||||||
// This approach avoids the credential being captured by process creation audit events,
|
if (!configPath && !globalConfig) {
|
||||||
// which are commonly logged. For more information, refer to
|
configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config')
|
||||||
// https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
|
}
|
||||||
|
|
||||||
|
// Configure a placeholder value. This approach avoids the credential being captured
|
||||||
|
// by process creation audit events, which are commonly logged. For more information,
|
||||||
|
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
|
||||||
await this.git.config(
|
await this.git.config(
|
||||||
this.tokenConfigKey,
|
this.tokenConfigKey,
|
||||||
this.tokenPlaceholderConfigValue,
|
this.tokenPlaceholderConfigValue,
|
||||||
false,
|
globalConfig
|
||||||
false,
|
|
||||||
credentialsConfigPath
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Replace the placeholder in the credentials config file
|
// Replace the placeholder
|
||||||
await this.replaceTokenPlaceholder(credentialsConfigPath)
|
await this.replaceTokenPlaceholder(configPath || '')
|
||||||
|
|
||||||
// Add include or includeIf to reference the credentials config
|
|
||||||
if (globalConfig) {
|
|
||||||
// Global config file is temporary
|
|
||||||
await this.git.config('include.path', credentialsConfigPath, true)
|
|
||||||
} else {
|
|
||||||
// Host git directory
|
|
||||||
let gitDir = path.join(this.git.getWorkingDirectory(), '.git')
|
|
||||||
gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
|
|
||||||
|
|
||||||
// Configure host includeIf
|
|
||||||
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
|
|
||||||
await this.git.config(hostIncludeKey, credentialsConfigPath)
|
|
||||||
this.credentialsIncludeKeys.push(hostIncludeKey)
|
|
||||||
|
|
||||||
// Container git directory
|
|
||||||
const githubWorkspace = process.env['GITHUB_WORKSPACE']
|
|
||||||
assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
|
|
||||||
const workingDirectory = this.git.getWorkingDirectory()
|
|
||||||
let relativePath = path.relative(githubWorkspace, workingDirectory)
|
|
||||||
relativePath = relativePath.replace(/\\/g, '/') // Use forward slashes, even on Windows
|
|
||||||
const containerGitDir = path.posix.join(
|
|
||||||
'/github/workspace',
|
|
||||||
relativePath,
|
|
||||||
'.git'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Container credentials config path
|
|
||||||
const containerCredentialsPath = path.posix.join(
|
|
||||||
'/github/runner_temp',
|
|
||||||
path.basename(credentialsConfigPath)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Configure container includeIf
|
|
||||||
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
|
|
||||||
await this.git.config(containerIncludeKey, containerCredentialsPath)
|
|
||||||
this.credentialsIncludeKeys.push(containerIncludeKey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async replaceTokenPlaceholder(configPath: string): Promise<void> {
|
private async replaceTokenPlaceholder(configPath: string): Promise<void> {
|
||||||
@@ -440,73 +343,137 @@ class GitAuthHelper {
|
|||||||
|
|
||||||
// SSH command
|
// SSH command
|
||||||
await this.removeGitConfig(SSH_COMMAND_KEY)
|
await this.removeGitConfig(SSH_COMMAND_KEY)
|
||||||
await this.removeSubmoduleGitConfig(SSH_COMMAND_KEY)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeToken(): Promise<void> {
|
private async removeToken(): Promise<void> {
|
||||||
// Remove HTTP extra header
|
// Remove HTTP extra header from local git config and submodule configs
|
||||||
await this.removeGitConfig(this.tokenConfigKey)
|
await this.removeGitConfig(this.tokenConfigKey)
|
||||||
await this.removeSubmoduleGitConfig(this.tokenConfigKey)
|
|
||||||
|
|
||||||
// Remove includeIf entries that point to git-credentials-*.config files
|
//
|
||||||
// This is more aggressive than tracking keys, but necessary since cleanup
|
// Cleanup actions/checkout@v6 style credentials
|
||||||
// runs in a post-step where this.credentialsIncludeKeys is empty
|
//
|
||||||
|
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(
|
||||||
|
configKey: string,
|
||||||
|
submoduleOnly: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
|
if (!submoduleOnly) {
|
||||||
|
if (
|
||||||
|
(await this.git.configExists(configKey)) &&
|
||||||
|
!(await this.git.tryConfigUnset(configKey))
|
||||||
|
) {
|
||||||
|
// Load the config contents
|
||||||
|
core.warning(`Failed to remove '${configKey}' from the git config`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = regexpHelper.escape(configKey)
|
||||||
|
await this.git.submoduleForeach(
|
||||||
|
// wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
|
||||||
|
`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
|
||||||
|
*/
|
||||||
|
private async removeIncludeIfCredentials(
|
||||||
|
configPath?: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const credentialsPaths = new Set<string>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all includeIf.gitdir keys
|
// Get all includeIf.gitdir keys
|
||||||
const keys = await this.git.tryGetConfigKeys('^includeIf\\.gitdir:')
|
const keys = await this.git.tryGetConfigKeys(
|
||||||
|
'^includeIf\\.gitdir:',
|
||||||
|
false, // globalConfig?
|
||||||
|
configPath
|
||||||
|
)
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
// Get all values for this key
|
// Get all values for this key
|
||||||
const values = await this.git.tryGetConfigValues(key)
|
const values = await this.git.tryGetConfigValues(
|
||||||
|
key,
|
||||||
|
false, // globalConfig?
|
||||||
|
configPath
|
||||||
|
)
|
||||||
if (values.length > 0) {
|
if (values.length > 0) {
|
||||||
// Remove only values that match git-credentials-<uuid>.config pattern
|
// Remove only values that match git-credentials-<uuid>.config pattern
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (/git-credentials-[0-9a-f-]+\.config$/i.test(value)) {
|
if (this.testCredentialsConfigPath(value)) {
|
||||||
await this.git.tryConfigUnsetValue(key, value)
|
credentialsPaths.add(value)
|
||||||
|
await this.git.tryConfigUnsetValue(key, value, false, configPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore errors - this is cleanup code
|
// Ignore errors - this is cleanup code
|
||||||
core.debug(`Error during includeIf cleanup: ${err}`)
|
core.debug(
|
||||||
|
`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove submodule includeIf
|
return Array.from(credentialsPaths)
|
||||||
await this.git.submoduleForeach(
|
|
||||||
`sh -c "git config --local --get-regexp '^includeif\\.' && git config --local --remove-section includeif || :"`,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
// Remove credentials config file
|
|
||||||
if (this.credentialsConfigPath) {
|
|
||||||
try {
|
|
||||||
await io.rmRF(this.credentialsConfigPath)
|
|
||||||
} catch (err) {
|
|
||||||
core.debug(`${(err as any)?.message ?? err}`)
|
|
||||||
core.warning(
|
|
||||||
`Failed to remove credentials config '${this.credentialsConfigPath}'`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeGitConfig(configKey: string): Promise<void> {
|
/**
|
||||||
if (
|
* Tests if a path matches the git-credentials-*.config pattern used by newer versions.
|
||||||
(await this.git.configExists(configKey)) &&
|
* @param path The path to test
|
||||||
!(await this.git.tryConfigUnset(configKey))
|
* @returns True if the path matches the credentials config pattern
|
||||||
) {
|
*/
|
||||||
// Load the config contents
|
private testCredentialsConfigPath(path: string): boolean {
|
||||||
core.warning(`Failed to remove '${configKey}' from the git config`)
|
return /git-credentials-[0-9a-f-]+\.config$/i.test(path)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async removeSubmoduleGitConfig(configKey: string): Promise<void> {
|
|
||||||
const pattern = regexpHelper.escape(configKey)
|
|
||||||
await this.git.submoduleForeach(
|
|
||||||
// Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline.
|
|
||||||
`sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ export interface IGitCommandManager {
|
|||||||
configKey: string,
|
configKey: string,
|
||||||
configValue: string,
|
configValue: string,
|
||||||
globalConfig?: boolean,
|
globalConfig?: boolean,
|
||||||
add?: boolean,
|
add?: boolean
|
||||||
configFile?: string
|
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
|
configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
|
||||||
fetch(
|
fetch(
|
||||||
@@ -42,6 +41,7 @@ export interface IGitCommandManager {
|
|||||||
}
|
}
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
getDefaultBranch(repositoryUrl: string): Promise<string>
|
getDefaultBranch(repositoryUrl: string): Promise<string>
|
||||||
|
getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
|
||||||
getWorkingDirectory(): string
|
getWorkingDirectory(): string
|
||||||
init(): Promise<void>
|
init(): Promise<void>
|
||||||
isDetached(): Promise<boolean>
|
isDetached(): Promise<boolean>
|
||||||
@@ -60,11 +60,24 @@ export interface IGitCommandManager {
|
|||||||
tagExists(pattern: string): Promise<boolean>
|
tagExists(pattern: string): Promise<boolean>
|
||||||
tryClean(): Promise<boolean>
|
tryClean(): Promise<boolean>
|
||||||
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
|
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
|
||||||
tryConfigUnsetValue(configKey: string, configValue: string, globalConfig?: boolean): Promise<boolean>
|
tryConfigUnsetValue(
|
||||||
|
configKey: string,
|
||||||
|
configValue: string,
|
||||||
|
globalConfig?: boolean,
|
||||||
|
configFile?: string
|
||||||
|
): Promise<boolean>
|
||||||
tryDisableAutomaticGarbageCollection(): Promise<boolean>
|
tryDisableAutomaticGarbageCollection(): Promise<boolean>
|
||||||
tryGetFetchUrl(): Promise<string>
|
tryGetFetchUrl(): Promise<string>
|
||||||
tryGetConfigValues(configKey: string, globalConfig?: boolean): Promise<string[]>
|
tryGetConfigValues(
|
||||||
tryGetConfigKeys(pattern: string, globalConfig?: boolean): Promise<string[]>
|
configKey: string,
|
||||||
|
globalConfig?: boolean,
|
||||||
|
configFile?: string
|
||||||
|
): Promise<string[]>
|
||||||
|
tryGetConfigKeys(
|
||||||
|
pattern: string,
|
||||||
|
globalConfig?: boolean,
|
||||||
|
configFile?: string
|
||||||
|
): Promise<string[]>
|
||||||
tryReset(): Promise<boolean>
|
tryReset(): Promise<boolean>
|
||||||
version(): Promise<GitVersion>
|
version(): Promise<GitVersion>
|
||||||
}
|
}
|
||||||
@@ -227,15 +240,9 @@ class GitCommandManager {
|
|||||||
configKey: string,
|
configKey: string,
|
||||||
configValue: string,
|
configValue: string,
|
||||||
globalConfig?: boolean,
|
globalConfig?: boolean,
|
||||||
add?: boolean,
|
add?: boolean
|
||||||
configFile?: string
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const args: string[] = ['config']
|
const args: string[] = ['config', globalConfig ? '--global' : '--local']
|
||||||
if (configFile) {
|
|
||||||
args.push('--file', configFile)
|
|
||||||
} else {
|
|
||||||
args.push(globalConfig ? '--global' : '--local')
|
|
||||||
}
|
|
||||||
if (add) {
|
if (add) {
|
||||||
args.push('--add')
|
args.push('--add')
|
||||||
}
|
}
|
||||||
@@ -333,6 +340,21 @@ class GitCommandManager {
|
|||||||
throw new Error('Unexpected output when retrieving default branch')
|
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 {
|
getWorkingDirectory(): string {
|
||||||
return this.workingDirectory
|
return this.workingDirectory
|
||||||
}
|
}
|
||||||
@@ -468,18 +490,18 @@ class GitCommandManager {
|
|||||||
async tryConfigUnsetValue(
|
async tryConfigUnsetValue(
|
||||||
configKey: string,
|
configKey: string,
|
||||||
configValue: string,
|
configValue: string,
|
||||||
globalConfig?: boolean
|
globalConfig?: boolean,
|
||||||
|
configFile?: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const output = await this.execGit(
|
const args = ['config']
|
||||||
[
|
if (configFile) {
|
||||||
'config',
|
args.push('--file', configFile)
|
||||||
globalConfig ? '--global' : '--local',
|
} else {
|
||||||
'--unset',
|
args.push(globalConfig ? '--global' : '--local')
|
||||||
configKey,
|
}
|
||||||
configValue
|
args.push('--unset', configKey, configValue)
|
||||||
],
|
|
||||||
true
|
const output = await this.execGit(args, true)
|
||||||
)
|
|
||||||
return output.exitCode === 0
|
return output.exitCode === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,45 +533,52 @@ class GitCommandManager {
|
|||||||
|
|
||||||
async tryGetConfigValues(
|
async tryGetConfigValues(
|
||||||
configKey: string,
|
configKey: string,
|
||||||
globalConfig?: boolean
|
globalConfig?: boolean,
|
||||||
|
configFile?: string
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const output = await this.execGit(
|
const args = ['config']
|
||||||
[
|
if (configFile) {
|
||||||
'config',
|
args.push('--file', configFile)
|
||||||
globalConfig ? '--global' : '--local',
|
} else {
|
||||||
'--get-all',
|
args.push(globalConfig ? '--global' : '--local')
|
||||||
configKey
|
}
|
||||||
],
|
args.push('--get-all', configKey)
|
||||||
true
|
|
||||||
)
|
const output = await this.execGit(args, true)
|
||||||
|
|
||||||
if (output.exitCode !== 0) {
|
if (output.exitCode !== 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return output.stdout.trim().split('\n').filter(value => value.trim())
|
return output.stdout
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(value => value.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
async tryGetConfigKeys(
|
async tryGetConfigKeys(
|
||||||
pattern: string,
|
pattern: string,
|
||||||
globalConfig?: boolean
|
globalConfig?: boolean,
|
||||||
|
configFile?: string
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const output = await this.execGit(
|
const args = ['config']
|
||||||
[
|
if (configFile) {
|
||||||
'config',
|
args.push('--file', configFile)
|
||||||
globalConfig ? '--global' : '--local',
|
} else {
|
||||||
'--name-only',
|
args.push(globalConfig ? '--global' : '--local')
|
||||||
'--get-regexp',
|
}
|
||||||
pattern
|
args.push('--name-only', '--get-regexp', pattern)
|
||||||
],
|
|
||||||
true
|
const output = await this.execGit(args, true)
|
||||||
)
|
|
||||||
|
|
||||||
if (output.exitCode !== 0) {
|
if (output.exitCode !== 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return output.stdout.trim().split('\n').filter(key => key.trim())
|
return output.stdout
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(key => key.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
async tryReset(): Promise<boolean> {
|
async tryReset(): Promise<boolean> {
|
||||||
|
|||||||
@@ -118,4 +118,10 @@ export interface IGitSourceSettings {
|
|||||||
* User override on the GitHub Server/Host URL that hosts the repository to be cloned
|
* User override on the GitHub Server/Host URL that hosts the repository to be cloned
|
||||||
*/
|
*/
|
||||||
githubServerUrl: string | undefined
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as core from '@actions/core'
|
|||||||
import * as fsHelper from './fs-helper'
|
import * as fsHelper from './fs-helper'
|
||||||
import * as github from '@actions/github'
|
import * as github from '@actions/github'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
import * as unsafePrCheckoutHelper from './unsafe-pr-checkout-helper'
|
||||||
import * as workflowContextHelper from './workflow-context-helper'
|
import * as workflowContextHelper from './workflow-context-helper'
|
||||||
import {IGitSourceSettings} from './git-source-settings'
|
import {IGitSourceSettings} from './git-source-settings'
|
||||||
|
|
||||||
@@ -161,5 +162,18 @@ export async function getInputs(): Promise<IGitSourceSettings> {
|
|||||||
result.githubServerUrl = core.getInput('github-server-url')
|
result.githubServerUrl = core.getInput('github-server-url')
|
||||||
core.debug(`GitHub Host URL = ${result.githubServerUrl}`)
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
return select(github.context.payload, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
src/unsafe-pr-checkout-helper.ts
Normal file
88
src/unsafe-pr-checkout-helper.ts
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user