如何安全使用GitLab CICD SSH部署

1. 背景

使用GitLab CICD,在部署方面,主要有两种方式:

  • 部署到K8S集群

    • Push模式:流水线通过kubectl执行命令部署,这需要把K8S的权限给流水线,存在安全风险

    • Pull模式:使用GitLab Agent for Kubernetes或ArgoCD,通过GitOps的方式“监听”GitLab的变化,触发部署

  • 部署到服务器

    目前仍有不少企业因为行业性质或者场景所限,没有使用K8S等云原生技术,还在采用传统的服务器方式进行部署。一般使用sshscprsync等命令部署到服务器。GitLab也提供了基于SSH keys的部署。详见:Using SSH keys with GitLab CI/CD | GitLab

    需要说明的是,如果是使用专用的编译机进行编译构建,然后部署到指定的服务器,只需要实现编译机和部署服务器的免密SSH登陆即可,相对简单。但如果使用容器进行编译构建,然后部署到服务器,就需要按照上面文档中提到的,配合GitLab CI/CD环境变量,将SSH_PRIVATE_KEY等变量存储到GitLab Project、Group或Instance中,实现复用。且可以通过GitLab CI/CD环境变量的Mask设置,掩藏这些变量在CICD日志中的显示。详见:GitLab CI/CD variables | GitLab

    但遗憾的是Mask功能目前是有限制的,对于SSH_PRIVATE_KEY这种多行的变量无法直接使用Mask功能。这样开发人员就可以在.gitlab-cti.yml文件的脚本中执行echo $SSH_PRIVATE_KEY,在流水线的日志中输出SSH Keys,存在密钥泄露风险。

    The value of the variable must:

    • Be a single line.
    • Be 8 characters or longer, consisting only of:
    • Not match the name of an existing predefined or custom CI/CD variable.

这个问题在GitLab的Issue上挂了有一年多 ,看样子短时间没法解决。有没有其他方式Mask SSH_PRIVATE_KEY?于是开始了各种折腾。

2. 方案

2.1 编码存储

SSH Keys不能直接Mask,但Mask的要求里面是支持Base64的。所以把SSH Keys先用Base64编码,存到CICD环境变量中,这样就可以Mask了,然后在.gitlab-ci.yml中解码,就可以在不影响功能的前提下实现效果。看看操作步骤:

  • Base64编码SSH_PRIVATE_KEY

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 输入示例
    echo "-----BEGIN RSA PRIVATE KEY-----
    MIIEogIBAAKCAQEAsWchpjSe6HW8dS/DdmokMqET2+eCvD8ysOeju3Ur3cbXtZF1
    *****
    pbPfj6i+faMGc1wbP+Svh8P5bcWTJZvZcP87D/HRmSFz6xcT014=
    -----END RSA PRIVATE KEY-----" | base64
    # 输出示例:Base64编码
    LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb2dJQkFBS0NBUUVBc1djaHBqU2U2SFc4ZFMvRG
    *****
    cjRzNmVjY25ZRUZxb1NSTGVNU2xMb1ZreU5VZEpQUjJRa1djQzRkVDVQZwpwYlBmajZpK2ZhTUdjMXdiUCtTdmg4UDViY1dUSlp2WmNQODdEL0hSbVNGejZ4Y1QwMTQ9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==
  • 将输出的Base64编码作为SSH_PRIVATE_KEY存储GitLab CICD环境变量,并Mask

  • 修改.gitlab-ci.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    before_script:
    - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
    - eval $(ssh-agent -s)
    # - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - echo "$SSH_PRIVATE_KEY" | base64 -d | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  • 运行测试,大功告成

这看上去是个不错的方案,但真的保证了SSH_PRIVATE_KEY安全么?我们本着Geek(作死)精神,测试一下:

  • 修改.gitlab-ci.yml,通过各种方式看看能不能打印出SSH_PRIVATE_KEY

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    before_script:
    - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
    - eval $(ssh-agent -s)
    # --- test begin ---
    - echo "$SSH_PRIVATE_KEY"
    - echo "$SSH_PRIVATE_KEY" >> output.txt
    - cat output.txt
    - echo "$SSH_PRIVATE_KEY" | base64 -d
    # --- test end ---
    - echo "$SSH_PRIVATE_KEY" | base64 -d | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  • 运行测试

    直接输出SSH_PRIVATE_KEY是被Mask了,但执行echo "$SSH_PRIVATE_KEY" | base64 -d,居然把SSH_PRIVATE_KEY打印了出来,所以这个方法还是存在一定的问题。

2.2 逐行存储

SSH Keys头部和尾部的-----BEGIN RSA PRIVATE KEY----------END RSA PRIVATE KEY-----不能Mask,但里面的内容,每一行可以单独作为一个环境变量存储并Mask,使用的时候再进行拼接,看看操作步骤:

  • SSH_PRIVATE_KEY每一行拆分成一个变量,进行存储,有多少行就要存多少变量,为了偷懒,此处只列了3行,实际上我这个SSH_PRIVATE_KEY除去头尾,有26行……

  • 修改.gitlab-ci.yml,并加入一些测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    before_script:
    - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
    - eval $(ssh-agent -s)
    # 拼接SSH_PRIVATE_KEY
    - |
    SSH_PRIVATE_KEY=$'-----BEGIN RSA PRIVATE KEY-----\n'
    SSH_PRIVATE_KEY=$SSH_PRIVATE_KEY$SSH_PRIVATE_KEY1$'\n'
    SSH_PRIVATE_KEY=$SSH_PRIVATE_KEY$SSH_PRIVATE_KEY2$'\n'
    SSH_PRIVATE_KEY=$SSH_PRIVATE_KEY$SSH_PRIVATE_KEY3$'\n'
    SSH_PRIVATE_KEY=$SSH_PRIVATE_KEY$'-----END RSA PRIVATE KEY-----'
    # --- test begin ---
    - echo "$SSH_PRIVATE_KEY"
    - echo "$SSH_PRIVATE_KEY" >> output.txt
    - cat output.txt
    # --- test end ---
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  • 运行测试

    方案行是行,实际操作起来要存26个变量然后还要拼起来,实在是太土了,能不能减少行数,存一行。

2.3 合并存储

Mask不支持空格,只支持@:.~,那我们尝试把SSH_PRIVATE_KEY除了头尾的部分合并成一行,把换行符替换成支持的符号,如.,然后再与头尾进行拼接。操作步骤如下:

  • 合并SSH_PRIVATE_KEY

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 输入示例
    echo "-----BEGIN RSA PRIVATE KEY-----
    MIIEogIBAAKCAQEAsWchpjSe6HW8dS/DdmokMqET2+eCvD8ysOeju3Ur3cbXtZF1
    *****
    pbPfj6i+faMGc1wbP+Svh8P5bcWTJZvZcP87D/HRmSFz6xcT014=
    -----END RSA PRIVATE KEY-----" | tr -d '\r' | tr "\n" "."

    #输出示例
    -----BEGIN RSA PRIVATE KEY-----.MIIEogIBAAKCAQEAsWchpjSe6HW8dS/DdmokMqET2+eCvD8ysOeju3Ur3cbXtZF1.LMb2Rq68/FPXsteLr4Y1ECKoy/YhFpyDw1h3cLm2WBUtRjt/Tq0ASbQCWAVkDsmx.uy28WofwfEKktzy3FmDSCXbvcOQgjChAmMbALWyH****=.-----END RSA PRIVATE KEY-----.
  • 将除头尾部分存入环境变量并Mask

  • 修改.gitlab-ci.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    before_script:
    - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
    - eval $(ssh-agent -s)
    # 拼接SSH_PRIVATE_KEY
    - SSH_PRIVATE_KEY=$'-----BEGIN RSA PRIVATE KEY-----\n'$SSH_PRIVATE_KEY$'\n-----END RSA PRIVATE KEY-----'
    # --- test begin ---
    - echo "$SSH_PRIVATE_KEY"
    - echo "$SSH_PRIVATE_KEY" >> output.txt
    - cat output.txt
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | tr "." "\n"
    # --- test end ---
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | tr "." "\n" | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  • 运行测试

    和“编码存储”的方案一样,跑的通,但依旧可以通过对应的方式,打印出SSH_PRIVATE_KEY

到这里,可以隐约猜到Mask变量的原理是简单做了一个是否包含字符串的判断。如果与环境变量的值匹配就显示[MASKED],如果不匹配就直接将变量显示出来。这也是为什么目前只允许值是单行且没有太多特殊符号的环境变量才可以MASK的原因。

2.4 打马赛克

为了验证上一步留下来的猜想,我设计了一个实验:

  • 恢复环境变量中的SSH_PRIVATE_KEY为原始内容,并且不做Mask,当然也无法Mask

  • 新建一个环境变量,值为SSH_PRIVATE_KEY的一部分内容,这里设置的是SSH_PRIVATE_KEY内容的第一行,然后设置为Mask

  • 恢复.gitlab-ci.yml文件,需要注意的是这里面没有任何关于MOSAIC环境变量的使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    before_script:
    - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY"
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r'| ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  • 运行测试

    正如猜想一样,即便没有使用MOSAIC环境变量,但它依然作为判断是否包含字符串而被执行了。

利用这个特性,我们可以通过设置几个马赛克变量,给SSH_PRIVATE_KEY的部分内容打码,看上去是个非常聪明的做法。但同样利用这个特性,可以让所有的Mask环境变量失效。

如果我们输出一个Mask环境变量的时候,往这个变量中插入点字符,破坏它判断包含字符串的逻辑,就可以把这个变量显示出来。我们尝试打印已经Mask的MOSAIC环境变量,往这个变量的第一个字符后插入一个空格:

1
echo "$MOSAIC" | awk 'BEGIN{FS=OFS=""}{$1=$1 " "}1'

执行结果:

这样就可以打印并推断出Mask环境变量的真实值了,也意味着我们上面的所有方式都不完美。

3. 结论

作为一般场景下使用,上面的四种方式任意选一个都可以实现基本的安全防护,正所谓防君子不防小人。如果要进一步提高安全性,还是如官方所说,上专业的密钥管理工具,如Vault,或者期待下GitLab在管理密钥这块功能的完善。


参考资料:

如何安全使用GitLab CICD SSH部署

https://wurang.net/gitlab-mask-sshkey/

作者

Wu Rang

发布于

2021-12-31

更新于

2022-03-11

许可协议

评论