diff --git a/README.md b/README.md index 9b6176d..a549a7d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/ # Default: true persist-credentials: '' + # Custom git credential helper + custom-credential-helper: '' + # Relative path under $GITHUB_WORKSPACE to place the repository path: '' diff --git a/action.yml b/action.yml index 75d5ae2..5208bae 100644 --- a/action.yml +++ b/action.yml @@ -52,6 +52,8 @@ inputs: persist-credentials: description: 'Whether to configure the token or SSH key with the local git config' default: true + custom-credential-helper: + description: 'Custom git credential helper' path: description: 'Relative path under $GITHUB_WORKSPACE to place the repository' clean: diff --git a/dist/index.js b/dist/index.js index e128adf..c902fe7 100644 --- a/dist/index.js +++ b/dist/index.js @@ -166,13 +166,16 @@ class GitAuthHelper { this.temporaryHomePath = ''; this.git = gitCommandManager; this.settings = gitSourceSettings || {}; - // Token auth header + this.credentialConfigKey = `credential.helper`; + const runnerTemp = process.env['RUNNER_TEMP'] || ''; + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); + const uniqueId = (0, uuid_1.v4)(); + this.credentialStorePath = path.join(runnerTemp, `${uniqueId}_credential_store`); + this.credentialConfigValue = `store --file ${this.credentialStorePath}`; const serverUrl = urlHelper.getServerUrl(this.settings.githubServerUrl); - this.tokenConfigKey = `http.${serverUrl.origin}/.extraheader`; // "origin" is SCHEME://HOSTNAME[:PORT] - const basicCredential = Buffer.from(`x-access-token:${this.settings.authToken}`, 'utf8').toString('base64'); - core.setSecret(basicCredential); - this.tokenPlaceholderConfigValue = `AUTHORIZATION: basic ***`; - this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}`; + serverUrl.username = `x-access-token`; + serverUrl.password = this.settings.authToken; + this.tokenCredential = serverUrl.href; // Instead of SSH URL this.insteadOfKey = `url.${serverUrl.origin}/.insteadOf`; // "origin" is SCHEME://HOSTNAME[:PORT] this.insteadOfValues.push(`git@${serverUrl.hostname}:`); @@ -246,7 +249,7 @@ class GitAuthHelper { catch (err) { // Unset in case somehow written to the real global config core.info('Encountered an error when attempting to configure token. Attempting unconfigure.'); - yield this.git.tryConfigUnset(this.tokenConfigKey, true); + yield this.git.tryConfigUnset(this.credentialConfigKey, true); throw err; } }); @@ -256,18 +259,12 @@ class GitAuthHelper { // Remove possible previous HTTPS instead of SSH yield this.removeGitConfig(this.insteadOfKey, true); if (this.settings.persistCredentials) { - // 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 - const output = 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 '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, this.settings.nestedSubmodules); - // Replace the placeholder - const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; - for (const configPath of configPaths) { - core.debug(`Replacing token placeholder in '${configPath}'`); - yield this.replaceTokenPlaceholder(configPath); + if (this.settings.customCredentialHelper) { + yield this.git.submoduleForeach(`sh -c "git config --local --add '${this.credentialConfigKey}' '${this.settings.customCredentialHelper}' && git config --local 'credential.useHttpPath' 'true'"`, this.settings.nestedSubmodules); } + 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 --add '${this.credentialConfigKey}' '${this.credentialConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, this.settings.nestedSubmodules); if (this.settings.sshKey) { // Configure core.sshCommand yield this.git.submoduleForeach(`git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`, this.settings.nestedSubmodules); @@ -306,7 +303,7 @@ class GitAuthHelper { const runnerTemp = process.env['RUNNER_TEMP'] || ''; assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); const uniqueId = (0, uuid_1.v4)(); - this.sshKeyPath = path.join(runnerTemp, uniqueId); + this.sshKeyPath = path.join(runnerTemp, `${uniqueId}_ssh_key`); stateHelper.setSshKeyPath(this.sshKeyPath); yield fs.promises.mkdir(runnerTemp, { recursive: true }); yield fs.promises.writeFile(this.sshKeyPath, this.settings.sshKey.trim() + '\n', { mode: 0o600 }); @@ -357,30 +354,17 @@ class GitAuthHelper { return __awaiter(this, void 0, void 0, function* () { // Validate args assert.ok((configPath && globalConfig) || (!configPath && !globalConfig), 'Unexpected configureToken parameter combinations'); + stateHelper.setCredentialStorePath(this.credentialStorePath); + yield fs.promises.writeFile(this.credentialStorePath, this.tokenCredential); // Default config path if (!configPath && !globalConfig) { configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config'); } - // 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) { - return __awaiter(this, void 0, void 0, function* () { - assert.ok(configPath, 'configPath is not defined'); - let content = (yield fs.promises.readFile(configPath)).toString(); - const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue); - if (placeholderIndex < 0 || - placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)) { - throw new Error(`Unable to replace auth placeholder in ${configPath}`); + if (this.settings.customCredentialHelper) { + yield this.git.config(this.credentialConfigKey, this.settings.customCredentialHelper, globalConfig, true); + yield this.git.config('credential.useHttpPath', 'true', globalConfig); } - assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined'); - content = content.replace(this.tokenPlaceholderConfigValue, this.tokenConfigValue); - yield fs.promises.writeFile(configPath, content); + yield this.git.config(this.credentialConfigKey, this.credentialConfigValue, globalConfig, true); }); } removeSsh() { @@ -413,8 +397,19 @@ class GitAuthHelper { } removeToken() { return __awaiter(this, void 0, void 0, function* () { - // HTTP extra header - yield this.removeGitConfig(this.tokenConfigKey); + var _a; + // Credential Helper + const credentialStorePath = this.credentialStorePath || stateHelper.CredentialStorePath; + if (credentialStorePath) { + try { + yield io.rmRF(credentialStorePath); + } + 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 credential store '${credentialStorePath}'`); + } + } + yield this.removeGitConfig(this.credentialConfigKey); }); } removeGitConfig(configKey_1) { @@ -1826,6 +1821,8 @@ function getInputs() { // Persist credentials result.persistCredentials = (core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE'; + // Custom credential helper + result.customCredentialHelper = core.getInput('custom-credential-helper'); // Workflow organization ID result.workflowOrganizationId = yield workflowContextHelper.getOrganizationId(); @@ -2347,7 +2344,7 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.setSafeDirectory = exports.setSshKnownHostsPath = exports.setSshKeyPath = exports.setRepositoryPath = exports.SshKnownHostsPath = exports.SshKeyPath = exports.PostSetSafeDirectory = exports.RepositoryPath = exports.IsPost = void 0; +exports.setCredentialStorePath = exports.setSafeDirectory = exports.setSshKnownHostsPath = exports.setSshKeyPath = exports.setRepositoryPath = exports.CredentialStorePath = exports.SshKnownHostsPath = exports.SshKeyPath = exports.PostSetSafeDirectory = exports.RepositoryPath = exports.IsPost = void 0; const core = __importStar(__nccwpck_require__(2186)); /** * Indicates whether the POST action is running @@ -2369,6 +2366,10 @@ exports.SshKeyPath = core.getState('sshKeyPath'); * The SSH known hosts path for the POST action. The value is empty during the MAIN action. */ exports.SshKnownHostsPath = core.getState('sshKnownHostsPath'); +/** + * The credential store path for git-credential-store + */ +exports.CredentialStorePath = core.getState('credentialStorePath'); /** * Save the repository path so the POST action can retrieve the value. */ @@ -2402,6 +2403,13 @@ exports.setSafeDirectory = setSafeDirectory; if (!exports.IsPost) { core.saveState('isPost', 'true'); } +/** + * Save the credential store path so the POST action can retrieve the value. + */ +function setCredentialStorePath(credentialStorePath) { + core.saveState('credentialStorePath', credentialStorePath); +} +exports.setCredentialStorePath = setCredentialStorePath; /***/ }), diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index 126e8e5..13570df 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -34,9 +34,10 @@ export function createAuthHelper( class GitAuthHelper { private readonly git: IGitCommandManager private readonly settings: IGitSourceSettings - private readonly tokenConfigKey: string - private readonly tokenConfigValue: string - private readonly tokenPlaceholderConfigValue: string + private readonly credentialConfigKey: string + private readonly credentialConfigValue: string + private readonly tokenCredential: string + private readonly credentialStorePath: string private readonly insteadOfKey: string private readonly insteadOfValues: string[] = [] private sshCommand = '' @@ -51,16 +52,20 @@ class GitAuthHelper { this.git = gitCommandManager this.settings = gitSourceSettings || ({} as unknown as IGitSourceSettings) - // Token auth header + this.credentialConfigKey = `credential.helper` + const runnerTemp = process.env['RUNNER_TEMP'] || '' + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') + const uniqueId = uuid() + this.credentialStorePath = path.join( + runnerTemp, + `${uniqueId}_credential_store` + ) + this.credentialConfigValue = `store --file ${this.credentialStorePath}` + const serverUrl = urlHelper.getServerUrl(this.settings.githubServerUrl) - this.tokenConfigKey = `http.${serverUrl.origin}/.extraheader` // "origin" is SCHEME://HOSTNAME[:PORT] - const basicCredential = Buffer.from( - `x-access-token:${this.settings.authToken}`, - 'utf8' - ).toString('base64') - core.setSecret(basicCredential) - this.tokenPlaceholderConfigValue = `AUTHORIZATION: basic ***` - this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}` + serverUrl.username = `x-access-token` + serverUrl.password = this.settings.authToken + this.tokenCredential = serverUrl.href // Instead of SSH URL this.insteadOfKey = `url.${serverUrl.origin}/.insteadOf` // "origin" is SCHEME://HOSTNAME[:PORT] @@ -143,7 +148,7 @@ class GitAuthHelper { core.info( 'Encountered an error when attempting to configure token. Attempting unconfigure.' ) - await this.git.tryConfigUnset(this.tokenConfigKey, true) + await this.git.tryConfigUnset(this.credentialConfigKey, true) throw err } } @@ -153,23 +158,19 @@ class GitAuthHelper { await this.removeGitConfig(this.insteadOfKey, true) if (this.settings.persistCredentials) { - // 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 - const output = await this.git.submoduleForeach( + if (this.settings.customCredentialHelper) { + await this.git.submoduleForeach( + `sh -c "git config --local --add '${this.credentialConfigKey}' '${this.settings.customCredentialHelper}' && git config --local 'credential.useHttpPath' 'true'"`, + this.settings.nestedSubmodules + ) + } + + 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 '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, + `sh -c "git config --local --add '${this.credentialConfigKey}' '${this.credentialConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, this.settings.nestedSubmodules ) - // Replace the placeholder - const configPaths: string[] = - output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [] - for (const configPath of configPaths) { - core.debug(`Replacing token placeholder in '${configPath}'`) - await this.replaceTokenPlaceholder(configPath) - } - if (this.settings.sshKey) { // Configure core.sshCommand await this.git.submoduleForeach( @@ -210,7 +211,7 @@ class GitAuthHelper { const runnerTemp = process.env['RUNNER_TEMP'] || '' assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') const uniqueId = uuid() - this.sshKeyPath = path.join(runnerTemp, uniqueId) + this.sshKeyPath = path.join(runnerTemp, `${uniqueId}_ssh_key`) stateHelper.setSshKeyPath(this.sshKeyPath) await fs.promises.mkdir(runnerTemp, {recursive: true}) await fs.promises.writeFile( @@ -282,40 +283,31 @@ class GitAuthHelper { 'Unexpected configureToken parameter combinations' ) + stateHelper.setCredentialStorePath(this.credentialStorePath) + await fs.promises.writeFile(this.credentialStorePath, this.tokenCredential) + // Default config path if (!configPath && !globalConfig) { configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config') } - // 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( - this.tokenConfigKey, - this.tokenPlaceholderConfigValue, - globalConfig - ) + if (this.settings.customCredentialHelper) { + await this.git.config( + this.credentialConfigKey, + this.settings.customCredentialHelper, + globalConfig, + true + ) - // Replace the placeholder - await this.replaceTokenPlaceholder(configPath || '') - } - - private async replaceTokenPlaceholder(configPath: string): Promise { - assert.ok(configPath, 'configPath is not defined') - let content = (await fs.promises.readFile(configPath)).toString() - const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue) - if ( - placeholderIndex < 0 || - placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue) - ) { - throw new Error(`Unable to replace auth placeholder in ${configPath}`) + await this.git.config('credential.useHttpPath', 'true', globalConfig) } - assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined') - content = content.replace( - this.tokenPlaceholderConfigValue, - this.tokenConfigValue + + await this.git.config( + this.credentialConfigKey, + this.credentialConfigValue, + globalConfig, + true ) - await fs.promises.writeFile(configPath, content) } private async removeSsh(): Promise { @@ -346,8 +338,20 @@ class GitAuthHelper { } private async removeToken(): Promise { - // HTTP extra header - await this.removeGitConfig(this.tokenConfigKey) + // Credential Helper + const credentialStorePath = + this.credentialStorePath || stateHelper.CredentialStorePath + if (credentialStorePath) { + try { + await io.rmRF(credentialStorePath) + } catch (err) { + core.debug(`${(err as any)?.message ?? err}`) + core.warning( + `Failed to remove credential store '${credentialStorePath}'` + ) + } + } + await this.removeGitConfig(this.credentialConfigKey) } private async removeGitConfig( diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts index 4e41ac3..ab122e9 100644 --- a/src/git-source-settings.ts +++ b/src/git-source-settings.ts @@ -104,6 +104,11 @@ export interface IGitSourceSettings { */ persistCredentials: boolean + /** + * Use following command/script as value for "credential..helper" + */ + customCredentialHelper: string | undefined + /** * Organization ID for the currently running workflow (used for auth settings) */ diff --git a/src/input-helper.ts b/src/input-helper.ts index 059232f..807916a 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -149,6 +149,9 @@ export async function getInputs(): Promise { result.persistCredentials = (core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE' + // Custom credential helper + result.customCredentialHelper = core.getInput('custom-credential-helper') + // Workflow organization ID result.workflowOrganizationId = await workflowContextHelper.getOrganizationId() diff --git a/src/state-helper.ts b/src/state-helper.ts index aa3eecc..28d08ae 100644 --- a/src/state-helper.ts +++ b/src/state-helper.ts @@ -25,6 +25,11 @@ export const SshKeyPath = core.getState('sshKeyPath') */ export const SshKnownHostsPath = core.getState('sshKnownHostsPath') +/** + * The credential store path for git-credential-store + */ +export const CredentialStorePath = core.getState('credentialStorePath') + /** * Save the repository path so the POST action can retrieve the value. */ @@ -58,3 +63,10 @@ export function setSafeDirectory() { if (!IsPost) { core.saveState('isPost', 'true') } + +/** + * Save the credential store path so the POST action can retrieve the value. + */ +export function setCredentialStorePath(credentialStorePath: string) { + core.saveState('credentialStorePath', credentialStorePath) +}