add ssh support (#163)
This commit is contained in:
		@@ -13,6 +13,7 @@ import {IGitSourceSettings} from './git-source-settings'
 | 
			
		||||
 | 
			
		||||
const IS_WINDOWS = process.platform === 'win32'
 | 
			
		||||
const HOSTNAME = 'github.com'
 | 
			
		||||
const SSH_COMMAND_KEY = 'core.sshCommand'
 | 
			
		||||
 | 
			
		||||
export interface IGitAuthHelper {
 | 
			
		||||
  configureAuth(): Promise<void>
 | 
			
		||||
@@ -36,6 +37,8 @@ class GitAuthHelper {
 | 
			
		||||
  private readonly tokenPlaceholderConfigValue: string
 | 
			
		||||
  private readonly insteadOfKey: string = `url.https://${HOSTNAME}/.insteadOf`
 | 
			
		||||
  private readonly insteadOfValue: string = `git@${HOSTNAME}:`
 | 
			
		||||
  private sshKeyPath = ''
 | 
			
		||||
  private sshKnownHostsPath = ''
 | 
			
		||||
  private temporaryHomePath = ''
 | 
			
		||||
  private tokenConfigValue: string
 | 
			
		||||
 | 
			
		||||
@@ -61,6 +64,7 @@ class GitAuthHelper {
 | 
			
		||||
    await this.removeAuth()
 | 
			
		||||
 | 
			
		||||
    // Configure new values
 | 
			
		||||
    await this.configureSsh()
 | 
			
		||||
    await this.configureToken()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -106,7 +110,9 @@ class GitAuthHelper {
 | 
			
		||||
 | 
			
		||||
      // Configure HTTPS instead of SSH
 | 
			
		||||
      await this.git.tryConfigUnset(this.insteadOfKey, true)
 | 
			
		||||
      await this.git.config(this.insteadOfKey, this.insteadOfValue, true)
 | 
			
		||||
      if (!this.settings.sshKey) {
 | 
			
		||||
        await this.git.config(this.insteadOfKey, this.insteadOfValue, true)
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      // Unset in case somehow written to the real global config
 | 
			
		||||
      core.info(
 | 
			
		||||
@@ -118,17 +124,15 @@ class GitAuthHelper {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async configureSubmoduleAuth(): Promise<void> {
 | 
			
		||||
    // Remove possible previous HTTPS instead of SSH
 | 
			
		||||
    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 commands = [
 | 
			
		||||
        `git config --local "${this.tokenConfigKey}" "${this.tokenPlaceholderConfigValue}"`,
 | 
			
		||||
        `git config --local "${this.insteadOfKey}" "${this.insteadOfValue}"`,
 | 
			
		||||
        `git config --local --show-origin --name-only --get-regexp remote.origin.url`
 | 
			
		||||
      ]
 | 
			
		||||
      const output = await this.git.submoduleForeach(
 | 
			
		||||
        commands.join(' && '),
 | 
			
		||||
        `git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`,
 | 
			
		||||
        this.settings.nestedSubmodules
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
@@ -139,10 +143,19 @@ class GitAuthHelper {
 | 
			
		||||
        core.debug(`Replacing token placeholder in '${configPath}'`)
 | 
			
		||||
        this.replaceTokenPlaceholder(configPath)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Configure HTTPS instead of SSH
 | 
			
		||||
      if (!this.settings.sshKey) {
 | 
			
		||||
        await this.git.submoduleForeach(
 | 
			
		||||
          `git config --local '${this.insteadOfKey}' '${this.insteadOfValue}'`,
 | 
			
		||||
          this.settings.nestedSubmodules
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async removeAuth(): Promise<void> {
 | 
			
		||||
    await this.removeSsh()
 | 
			
		||||
    await this.removeToken()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -152,6 +165,77 @@ class GitAuthHelper {
 | 
			
		||||
    await io.rmRF(this.temporaryHomePath)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async configureSsh(): Promise<void> {
 | 
			
		||||
    if (!this.settings.sshKey) {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Write key
 | 
			
		||||
    const runnerTemp = process.env['RUNNER_TEMP'] || ''
 | 
			
		||||
    assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
 | 
			
		||||
    const uniqueId = uuid()
 | 
			
		||||
    this.sshKeyPath = path.join(runnerTemp, uniqueId)
 | 
			
		||||
    stateHelper.setSshKeyPath(this.sshKeyPath)
 | 
			
		||||
    await fs.promises.mkdir(runnerTemp, {recursive: true})
 | 
			
		||||
    await fs.promises.writeFile(
 | 
			
		||||
      this.sshKeyPath,
 | 
			
		||||
      this.settings.sshKey.trim() + '\n',
 | 
			
		||||
      {mode: 0o600}
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    // Remove inherited permissions on Windows
 | 
			
		||||
    if (IS_WINDOWS) {
 | 
			
		||||
      const icacls = await io.which('icacls.exe')
 | 
			
		||||
      await exec.exec(
 | 
			
		||||
        `"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"`
 | 
			
		||||
      )
 | 
			
		||||
      await exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Write known hosts
 | 
			
		||||
    const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts')
 | 
			
		||||
    let userKnownHosts = ''
 | 
			
		||||
    try {
 | 
			
		||||
      userKnownHosts = (
 | 
			
		||||
        await fs.promises.readFile(userKnownHostsPath)
 | 
			
		||||
      ).toString()
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (err.code !== 'ENOENT') {
 | 
			
		||||
        throw err
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    let knownHosts = ''
 | 
			
		||||
    if (userKnownHosts) {
 | 
			
		||||
      knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n`
 | 
			
		||||
    }
 | 
			
		||||
    if (this.settings.sshKnownHosts) {
 | 
			
		||||
      knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n`
 | 
			
		||||
    }
 | 
			
		||||
    knownHosts += `# Begin implicitly added github.com\ngithub.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==\n# End implicitly added github.com\n`
 | 
			
		||||
    this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`)
 | 
			
		||||
    stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath)
 | 
			
		||||
    await fs.promises.writeFile(this.sshKnownHostsPath, knownHosts)
 | 
			
		||||
 | 
			
		||||
    // Configure GIT_SSH_COMMAND
 | 
			
		||||
    const sshPath = await io.which('ssh', true)
 | 
			
		||||
    let sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
 | 
			
		||||
      this.sshKeyPath
 | 
			
		||||
    )}"`
 | 
			
		||||
    if (this.settings.sshStrict) {
 | 
			
		||||
      sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'
 | 
			
		||||
    }
 | 
			
		||||
    sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
 | 
			
		||||
      this.sshKnownHostsPath
 | 
			
		||||
    )}"`
 | 
			
		||||
    core.info(`Temporarily overriding GIT_SSH_COMMAND=${sshCommand}`)
 | 
			
		||||
    this.git.setEnvironmentVariable('GIT_SSH_COMMAND', sshCommand)
 | 
			
		||||
 | 
			
		||||
    // Configure core.sshCommand
 | 
			
		||||
    if (this.settings.persistCredentials) {
 | 
			
		||||
      await this.git.config(SSH_COMMAND_KEY, sshCommand)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async configureToken(
 | 
			
		||||
    configPath?: string,
 | 
			
		||||
    globalConfig?: boolean
 | 
			
		||||
@@ -198,23 +282,55 @@ class GitAuthHelper {
 | 
			
		||||
    await fs.promises.writeFile(configPath, content)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async removeSsh(): Promise<void> {
 | 
			
		||||
    // SSH key
 | 
			
		||||
    const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
 | 
			
		||||
    if (keyPath) {
 | 
			
		||||
      try {
 | 
			
		||||
        await io.rmRF(keyPath)
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        core.debug(err.message)
 | 
			
		||||
        core.warning(`Failed to remove SSH key '${keyPath}'`)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // SSH known hosts
 | 
			
		||||
    const knownHostsPath =
 | 
			
		||||
      this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
 | 
			
		||||
    if (knownHostsPath) {
 | 
			
		||||
      try {
 | 
			
		||||
        await io.rmRF(knownHostsPath)
 | 
			
		||||
      } catch {
 | 
			
		||||
        // Intentionally empty
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // SSH command
 | 
			
		||||
    await this.removeGitConfig(SSH_COMMAND_KEY)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async removeToken(): Promise<void> {
 | 
			
		||||
    // HTTP extra header
 | 
			
		||||
    await this.removeGitConfig(this.tokenConfigKey)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async removeGitConfig(configKey: string): Promise<void> {
 | 
			
		||||
    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`)
 | 
			
		||||
  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(
 | 
			
		||||
      `git config --local --name-only --get-regexp ${pattern} && git config --local --unset-all ${configKey} || :`,
 | 
			
		||||
      `git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :`,
 | 
			
		||||
      true
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -18,9 +18,13 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
 | 
			
		||||
  core.info(
 | 
			
		||||
    `Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}`
 | 
			
		||||
  )
 | 
			
		||||
  const repositoryUrl = `https://${hostname}/${encodeURIComponent(
 | 
			
		||||
    settings.repositoryOwner
 | 
			
		||||
  )}/${encodeURIComponent(settings.repositoryName)}`
 | 
			
		||||
  const repositoryUrl = settings.sshKey
 | 
			
		||||
    ? `git@${hostname}:${encodeURIComponent(
 | 
			
		||||
        settings.repositoryOwner
 | 
			
		||||
      )}/${encodeURIComponent(settings.repositoryName)}.git`
 | 
			
		||||
    : `https://${hostname}/${encodeURIComponent(
 | 
			
		||||
        settings.repositoryOwner
 | 
			
		||||
      )}/${encodeURIComponent(settings.repositoryName)}`
 | 
			
		||||
 | 
			
		||||
  // Remove conflicting file path
 | 
			
		||||
  if (fsHelper.fileExistsSync(settings.repositoryPath)) {
 | 
			
		||||
 
 | 
			
		||||
@@ -10,5 +10,8 @@ export interface IGitSourceSettings {
 | 
			
		||||
  submodules: boolean
 | 
			
		||||
  nestedSubmodules: boolean
 | 
			
		||||
  authToken: string
 | 
			
		||||
  sshKey: string
 | 
			
		||||
  sshKnownHosts: string
 | 
			
		||||
  sshStrict: boolean
 | 
			
		||||
  persistCredentials: boolean
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -112,6 +112,12 @@ export function getInputs(): IGitSourceSettings {
 | 
			
		||||
  // Auth token
 | 
			
		||||
  result.authToken = core.getInput('token')
 | 
			
		||||
 | 
			
		||||
  // SSH
 | 
			
		||||
  result.sshKey = core.getInput('ssh-key')
 | 
			
		||||
  result.sshKnownHosts = core.getInput('ssh-known-hosts')
 | 
			
		||||
  result.sshStrict =
 | 
			
		||||
    (core.getInput('ssh-strict') || 'true').toUpperCase() === 'TRUE'
 | 
			
		||||
 | 
			
		||||
  // Persist credentials
 | 
			
		||||
  result.persistCredentials =
 | 
			
		||||
    (core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE'
 | 
			
		||||
 
 | 
			
		||||
@@ -59,13 +59,17 @@ function updateUsage(
 | 
			
		||||
 | 
			
		||||
    // Constrain the width of the description
 | 
			
		||||
    const width = 80
 | 
			
		||||
    let description = input.description as string
 | 
			
		||||
    let description = (input.description as string)
 | 
			
		||||
      .trimRight()
 | 
			
		||||
      .replace(/\r\n/g, '\n') // Convert CR to LF
 | 
			
		||||
      .replace(/ +/g, ' ') //    Squash consecutive spaces
 | 
			
		||||
      .replace(/ \n/g, '\n') //  Squash space followed by newline
 | 
			
		||||
    while (description) {
 | 
			
		||||
      // Longer than width? Find a space to break apart
 | 
			
		||||
      let segment: string = description
 | 
			
		||||
      if (description.length > width) {
 | 
			
		||||
        segment = description.substr(0, width + 1)
 | 
			
		||||
        while (!segment.endsWith(' ') && segment) {
 | 
			
		||||
        while (!segment.endsWith(' ') && !segment.endsWith('\n') && segment) {
 | 
			
		||||
          segment = segment.substr(0, segment.length - 1)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -77,15 +81,30 @@ function updateUsage(
 | 
			
		||||
        segment = description
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      description = description.substr(segment.length) // Remaining
 | 
			
		||||
      segment = segment.trimRight() // Trim the trailing space
 | 
			
		||||
      newReadme.push(`    # ${segment}`)
 | 
			
		||||
      // Check for newline
 | 
			
		||||
      const newlineIndex = segment.indexOf('\n')
 | 
			
		||||
      if (newlineIndex >= 0) {
 | 
			
		||||
        segment = segment.substr(0, newlineIndex + 1)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Append segment
 | 
			
		||||
      newReadme.push(`    # ${segment}`.trimRight())
 | 
			
		||||
 | 
			
		||||
      // Remaining
 | 
			
		||||
      description = description.substr(segment.length)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Input and default
 | 
			
		||||
    if (input.default !== undefined) {
 | 
			
		||||
      // Append blank line if description had paragraphs
 | 
			
		||||
      if ((input.description as string).trimRight().match(/\n[ ]*\r?\n/)) {
 | 
			
		||||
        newReadme.push(`    #`)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Default
 | 
			
		||||
      newReadme.push(`    # Default: ${input.default}`)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Input name
 | 
			
		||||
    newReadme.push(`    ${key}: ''`)
 | 
			
		||||
 | 
			
		||||
    firstInput = false
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,17 @@ export const IsPost = !!process.env['STATE_isPost']
 | 
			
		||||
export const RepositoryPath =
 | 
			
		||||
  (process.env['STATE_repositoryPath'] as string) || ''
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The SSH key path for the POST action. The value is empty during the MAIN action.
 | 
			
		||||
 */
 | 
			
		||||
export const SshKeyPath = (process.env['STATE_sshKeyPath'] as string) || ''
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The SSH known hosts path for the POST action. The value is empty during the MAIN action.
 | 
			
		||||
 */
 | 
			
		||||
export const SshKnownHostsPath =
 | 
			
		||||
  (process.env['STATE_sshKnownHostsPath'] as string) || ''
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Save the repository path so the POST action can retrieve the value.
 | 
			
		||||
 */
 | 
			
		||||
@@ -22,6 +33,24 @@ export function setRepositoryPath(repositoryPath: string) {
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Save the SSH key path so the POST action can retrieve the value.
 | 
			
		||||
 */
 | 
			
		||||
export function setSshKeyPath(sshKeyPath: string) {
 | 
			
		||||
  coreCommand.issueCommand('save-state', {name: 'sshKeyPath'}, sshKeyPath)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Save the SSH known hosts path so the POST action can retrieve the value.
 | 
			
		||||
 */
 | 
			
		||||
export function setSshKnownHostsPath(sshKnownHostsPath: string) {
 | 
			
		||||
  coreCommand.issueCommand(
 | 
			
		||||
    'save-state',
 | 
			
		||||
    {name: 'sshKnownHostsPath'},
 | 
			
		||||
    sshKnownHostsPath
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic.
 | 
			
		||||
// This is necessary since we don't have a separate entry point.
 | 
			
		||||
if (!IsPost) {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user