在诸多的互联网企业中,私有化部署GitLab平台是进行公司内部项目代码托管的最常用方式。
GitLab平台功能强大,除了用于进行Git项目的代码托管,还具备完善的CI/CD能力,能够帮助研发同学一站式的完成代码提交,项目编译,项目部署等工作,大大简化了DevOps流程中各种平台的对接工作。
这其中最重要的技术,就是GitLab平台提供的GitLab CI能力。它能够采用一个yaml格式的配置文件,完成整个项目的全流程建设,而不需要额外的平台配置(比如Jenkins)。
今天我们要探讨的,就是如何在采用GitLab CI的项目中,完成静态代码安全扫描,并具备安全卡点能力。
什么是GitLab CI
如题,我们首先来介绍一下强大的Gitlab CI 技术。
GitLab CI(Continuous Integration)是 GitLab 提供的一款持续集成/持续部署的解决方案,它能够帮助开发团队自动化构建、测试和部署应用程序。借助 GitLab CI,开发团队可以在代码发生变更时,自动构建、测试和部署应用程序,从而提高开发效率和软件质量。
GitLab CI 基于 .gitlab-ci.yml 文件来定义一系列的 Jobs(任务)。每个 Job 包含一个或多个具体的步骤,例如编译代码、运行测试、打包应用程序等。当一个 Job 完成后,可以根据其执行结果决定是否继续执行下一个 Job 或者终止整个流程。
GitLab CI 提供了许多有用的功能,例如并行构建、容器化构建、自定义环境变量、报告分析等。它还支持多种语言和框架,包括 Java、Python、Node.js、Ruby 等,以及容器化技术,如 Docker 和 Kubernetes。
使用 GitLab CI 可以提高开发效率,减少手动操作,提高代码质量和可靠性,并且便于管理和维护。同时,GitLab CI 与 GitLab 集成紧密,可以通过 GitLab 的界面来查看和管理 CI 流水线,更加方便。
我们来实践一下GitLab CI的使用。
什么是Gitlab CI Runner
Gitlab Runner是负责执行Gitlab CI任务的工作单元,我们需要为GitLab平台配置好GitLab CI Runner后,才可以使用GitLab CI,详细信息请查看https://docs.gitlab.com/runner/。
使用案例演示
我们在GitLab 平台上有一个Java项目,叫做ProjectJava
。我们需要使用GitLab CI技术来完整的实现项目测试,编译部署等工作。
首先我们需要在根目录下创建一个.gitlab-ci.yml
配置文件,写入以下内容:
stages: # 定义多个阶段 - build # 构建 - test # 测试 - deploy # 部署 build_job: # 定义一个构建任务 stage: build # 指定所属阶段 script: - mvn package # 执行命令:构建应用程序 test_job: # 定义一个测试任务 stage: test # 指定所属阶段 script: - mvn test # 执行命令:运行单元测试 deploy_job: # 定义一个部署任务 stage: deploy # 指定所属阶段 script: - ./deploy.sh # 执行命令:调用脚本部署应用程序 only: - master # 仅在 master 分支提交时执行
当我们在提交项目代码的时候,GitLab会自动运行根目录下的.gitlab-ci.yml配置文件,执行里面的指令。
GitLab CI最核心的是2个部分:stage
和job
。
前面有提到GitLab CI 是由一系列的job构成,job就是执行任务单元。但是这个job在什么时间点执行,就是由stage决定的。
我们在.gitlab-ci.yml配置文件里看到的如下代码:
stages: # 定义多个阶段 - build # 构建 - test # 测试 - deploy # 部署
就是项目自定义了3个stage,分别表示项目执行的三个阶段。
然后后面_job结尾的任务,都会有一个stage标签,表示这个任务是在哪个stage进行执行。
所以以上配置的执行顺序是这样的:
这样我们通过自定义stage和job,就能实现我们想要实现的任意功能。当然GitLab CI语法不只是这些,详细可查看:
https://docs.gitlab.com/ee/ci/quick_start/。
配置好.gitlab-ci.yml,我们把提交项目代码到gitlab平台,查看Pipeline流水线,就能够看到我们的各种任务被执行了。
如果研发业务都是使用Gitlab CI来进行编译部署,我们该如何接入安全扫描呢?
换言之,我们现在具备了独立的代码安全扫描引擎,该如何接入到这些项目里,帮助研发解决安全问题呢?
GitLab CI接入安全扫描的一般配置
一般来说,我们是通过添加安全扫描Job的方式来做这件事。
我们上面说过GitLab CI通过添加Stage和Job的方式进行管理,那我们可以添加一个名字叫做secscan
的stage,作为我们的安全扫描节点。
stages: # 定义多个阶段 - build # 构建 - secscan # 安全扫描 - test # 测试 - deploy # 部署
在这个扫描节点里,我们实现把相关信息传递给代码扫描引擎,完成扫描工作。
我们的Job可以叫做secscan-job,可以这么写:
secscan-job: stage: secscan script: - export MULT_COMMIT_BRANCH=${CI_COMMIT_BRANCH} - if [ ! "$MULT_COMMIT_BRANCH" ]; then export MULT_COMMIT_BRANCH=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}; fi - if [ ! "$MULT_COMMIT_BRANCH" ]; then export MULT_COMMIT_BRANCH=${CI_COMMIT_TAG}; fi - python3 /home/agent/gitlab_secscan.py --gitUrl "${CI_PROJECT_URL}.git" --gitCommitId ${CI_COMMIT_SHA} --gitBranch $MULT_COMMIT_BRANCH --gitProjectPath ${CI_PROJECT_PATH} --url ${CI_PIPELINE_URL} --users ${GITLAB_USER_LOGIN} --pipelineId ${CI_PIPELINE_ID}
Gitlab CI提供了非常多的环境变量,具体可查看https://docs.gitlab.com/ee/ci/variables/predefined_variables.html。
我们通过script
获取了当前本次提交的项目信息后,执行了/home/agent/gitlab_secscan.py
这个脚本来处理这些信息。
这个脚本在哪里?
前面我有提到,Gitlab CI的任务执行,都是通过Gitlab Runner来负责执行的,Gitlab Runner可以是物理机,docker镜像,甚至是K8S环境。
所以这个脚本应该放到Gitlab Runner环境里!这样在执行的时候就会自动执行这个脚本!
当然这个脚本的内容不是本文的重点,无非是实现获取这些参数,再传递给扫描引擎进行安全扫描,如图:
设计好如上的.gitlab-ci.yml后,我们提交程序,安全扫描Job就会被触发了。
安全卡点
一般来说,如果不需要因为安全问题对流程进行卡点的话,上面的配置就足够了。扫描发送到SAST扫描引擎,不影响Pipeline流水线的执行流程,不影响业务开发。安全方通过人工、自动化分析扫描结果,创建Jira,然后跟进漏洞修复。
但是安全不卡点还叫DevSecOps吗?又何谈安全左移呢?
当然你可以说,安全卡点导致误报率,业务影响什么的,这不在本文的讨论范围,以后有机会讨论。
如果我们现在需要做的,就是发现了严重的安全问题,比如log4j2组件调用,我们就是需要停止掉整个流水线操作,让业务修复漏洞后才可以继续,我们该怎么办?
利用Gitlab CI实现卡点,还是比较简单的,实现原理很简单:如果某一个Job运行过程中,返回非0错误码,当前Job会自动停止,并阻断后续Job的运行。
我们来试一下:
secscan-job: stage: secscan script: - I am done! - exit 255
我们直接模拟返回255错误,运行流水线,发现secscan-job运行失败的同时,后续流水线也被阻断了。
那么我们就可以在我们的gitlab_secscan.py
脚本里做判断,如果扫描发现安全漏洞,通过exit返回错误即可。
优化后的GitLab CI接入安全扫描
我们将secscan-job写到项目的.gitlab-ci.yml里,看起来没什么问题,但是作为安全人员,我们面对成千上万的项目都需要接入安全扫描,我们该怎么办?
号召研发都在自己的.gitlab-ci.yml中增加secscan-job任务?
本质上讲,增加安全扫描是给研发添麻烦,对方就是不加,你怎么识别?
即使加上了,后续变更怎么办? 再让所有研发修改一次?
变更需要所有研发配合,动静太大,实现困难。
如果项目并不是太多,我们可以将基础方案进行改进,使用gitlab ci的include
语法完成优化工作,官方文档:https://docs.gitlab.com/ee/ci/yaml/includes.html。
像PHP提供的include
一样,Gitlab CI允许使用include引入公共模板,解决相同配置统一管控的方案。
我们将我们基础方案中的公共部分统一放入公共模板:
http://gitlab.xxx.com/common/gitlab_ci_template/.base_gitlab_ci.yml
secscan-job: stage: secscan script: - export MULT_COMMIT_BRANCH=${CI_COMMIT_BRANCH} - if [ ! "$MULT_COMMIT_BRANCH" ]; then export MULT_COMMIT_BRANCH=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}; fi - if [ ! "$MULT_COMMIT_BRANCH" ]; then export MULT_COMMIT_BRANCH=${CI_COMMIT_TAG}; fi - python3 /home/agent/gitlab_secscan.py --gitUrl "${CI_PROJECT_URL}.git" --gitCommitId ${CI_COMMIT_SHA} --gitBranch $MULT_COMMIT_BRANCH --gitProjectPath ${CI_PROJECT_PATH} --url ${CI_PIPELINE_URL} --users ${GITLAB_USER_LOGIN} --pipelineId ${CI_PIPELINE_ID}
然后再在各个子项目中使用include引入这个模板:
include: - project: 'common/gitlab_ci_template' # 项目名称 ref: master # 分支 file: 'common/gitlab_ci_template/.base_gitlab_ci.yml' # 公共配置文件 stages: # 定义多个阶段 - build # 构建 - test # 测试 - secscan # 安全扫描节点 - deploy # 部署 build_job: # 定义一个构建任务 stage: build # 指定所属阶段 script: - mvn package # 执行命令:构建应用程序 test_job: # 定义一个测试任务 stage: test # 指定所属阶段 script: - mvn test # 执行命令:运行单元测试 deploy_job: # 定义一个部署任务 stage: deploy # 指定所属阶段 script: - ./deploy.sh # 执行命令:调用脚本部署应用程序 only: - master # 仅在 master 分支提交时执行
这样就解决问题啦,我们可以让研发统一按照这个模板接入,如果后续安全扫描节点有变更,我们更改common/gitlab_ci_template
项目就可以啦!
不过你有没有发现问题,我们的common/gitlab_ci_template
公共模板里,secscan-job的stage是啥?是secscan
,如果业务的项目代码里没有这个stage怎么办,那肯定是不能运行的!
Gitlab CI的默认Stage机制
如果项目模版中定义了自己的Stage,那么在include
的公共模版中定义的Stage是无法生效的(会报错,可自行尝试)。要解决这个问题,我们需要研究一下Gitlab CI 的Stage机制。
我们来看一下官方文档对Stages的描述(https://docs.gitlab.com/ee/ci/yaml/#stages):
Use
stages
to define stages that contain groups of jobs. Usestage
in a job to configure the job to run in a specific stage.If
stages
is not defined in the.gitlab-ci.yml
file, the default pipeline stages are:
如果项目并没有在gitlab-ci.yml中配置Stages,那么默认是以上的Stages,可以直接使用,不需要定义。
但是如果用户项目自定义了Stages,那么就不能直接默认的Stages了。
我们注意到第一个(.pre)和最后一个(.post)两个stage跟其他不太一样,看一下文档描述。
If a pipeline contains only jobs in the .pre or .post stages, it does not run. There must be at least one other job in a different stage. .pre and .post stages can be used in required pipeline configuration to define compliance jobs that must run before or after project pipeline jobs.
意思为.pre
和.post
两个stage为默认执行的stage,如果在项目里有其他stage被执行,那么再执行以前,会先执行.pre
stage,执行完成之后,会执行.post
stage!
并且这两个stage是不需要额外定义的!
回到我们扫描配置改进计划中,这样我们在公共模版中把我们的secscan-job
放入.pre
Stage 就可以了。
.pre stage 会在第一个具体定义的stage执行前被执行,完全符合我们进行安全卡点的需求,我们需要对触发Pipeline编译、部署任务的流水线进行安全检测和卡点,对那些不触发流水线的一般提交不作处理。
具体公共模版如下:
secscan-job: stage: .pre script: - export MULT_COMMIT_BRANCH=${CI_COMMIT_BRANCH} - if [ ! "$MULT_COMMIT_BRANCH" ]; then export MULT_COMMIT_BRANCH=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}; fi - if [ ! "$MULT_COMMIT_BRANCH" ]; then export MULT_COMMIT_BRANCH=${CI_COMMIT_TAG}; fi - python3 /home/agent/gitlab_secscan.py --gitUrl "${CI_PROJECT_URL}.git" --gitCommitId ${CI_COMMIT_SHA} --gitBranch $MULT_COMMIT_BRANCH --gitProjectPath ${CI_PROJECT_PATH} --url ${CI_PIPELINE_URL} --users ${GITLAB_USER_LOGIN} --pipelineId ${CI_PIPELINE_ID}
提交代码执行一下看看,.pre
Stage 被执行,我们的安全扫描Job被第一个触发!
到目前为止,真正实现了只需要让项目引入我们的公共模版即可,不需要项目的.gitlab-ci.yml做任何改动!
include: - project: 'commom/gitlab_ci_template' # 项目名称 ref: master # 分支 file: 'commom/gitlab_ci_template/.base_gitlab_ci.yml' # 公共配置文件
如果测试发现,push操作可以正常触发
secscan-job
,但是Merge Request事件并没有触发,那么可以使用解决方案:https://gitlab.com/gitlab-org/gitlab-runner/-/issues/5970
解决方案是Job配置添加:
rules: - when: on_success
望知晓。
具备完善卡点能力的GitLab CI接入安全扫描
通过上面的优化,我们完美的实现了让项目除了引入我们的模版外,不需要做任何变更的接入方式。
但是现在依然存在的问题是:如果项目没有接入公共模版,或者因为安全问题被卡住了,用户也完全可以先把公共扫描模版注释掉,提交完成代码后再恢复。
这样我们的安全扫描卡点就形同虚设,很容易就绕过!
有没有办法实现强制卡点呢,研发同学无法跳过的那种!
有的,那就是通过GitLab Runner卡点的方式进行扫描。
通过上图我们发现,之前的接入方案都是在REPO端,这部分是由项目同事控制的,我们没有办法做到强制卡点。
如果我们想不受项目的控制,就可以考虑把安全检测卡点能力放到右侧的Gitlab Runner 端。
这么做有如下优势:
- 无需项目接入,调用Pipeline时自动进行安全检测
- 新增项目“零成本”,“无感知”接入
- 强制接入安全检测,无法主动绕过
如何实现?
前面有提到,我们所有的Job都是在Gtilab Runner上被执行,无论是安全扫描Job还是其他业务Job。
如果业务Job在执行前,能够给一个Hook事件,我们就可以利用这个Hook事件来执行前置的安全扫描工作。
幸运的是,我们发现Gitlab CI Runner配置中提供了这样一个事件:pre_clone_script
。
pre_clone_script
此配置允许Gitlab Runner在执行代码下载操作之前,执行一段用户自定义的shell脚本。一般可以用此参数设置一些环境变量等执行前置信息,详情请参照https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section。
如果此shell脚本返回exit -1,则当前job会被自动停止,并被在pipeline里标识为failed。
如果我们的Gitlab Runner 是用的shell模式,那么我们只需要在我们的Gitlab Runner Server的配置文件(/etc/gitlab-runner/config.toml
)里,调整如下内容:
[[runners]] name = "ubuntu" url = "https://gitlab.xxx.com/" token = "AUt-sfU1xxxxxx" executor = "shell" pre_clone_script="echo pre_clone_script && pwd" pre_build_script="echo pre_build_script_test && pwd" [runners.custom_build_dir] [runners.cache] [runners.cache.s3] [runners.cache.gcs] [runners.cache.azure]
我们实际上增加的内容是:
pre_clone_script="echo pre_clone_script && pwd" pre_build_script="echo pre_build_script_test && pwd"
这样Gitlab Runner执行任务前,会先执行
echo pre_clone_script && pwd
脚本,再执行Job内容。
我们配置好后,提交代码试一下。
各个任务都正确运行了,我们看一下任务的日志:
我们在Gitlab Runner 配置文件中增加的shell脚本被执行了,但是项目本身并没有做任何配置。
到此,我们完成了在Gitlab Runner端控制项目代码的方案,将测试的shell脚本换成代码安全扫描的shell脚本即可。
比如我们编写脚本seccheck.sh
:
echo "Start security scan" target_agent_path="/tmp/sec_agent" agent_api="https://xxx.com/gitlab/sec_agent" # 远程的安全agent地址 # 如果Runner是docker、k8s模式,可以采用这种远程下载agent再执行的方式,如果是shell模式则不需要,直接上传agent即可 { download_error=$(wget --tries=2 --timeout=10 --quiet -O $target_agent_path $agent_api 2>&1 >&3 3>&-); } 3>&1 || { exit 0 } chmod +x /tmp/sec_agent # 发送git项目数据给agent,agent再使用sast引擎的api进行检测,并返回结果,判断是否卡点,如果errcode==255,流程会被卡点 { security_agent_errors=$(/tmp/sec_agent --gitUrl "${CI_PROJECT_URL}.git" --gitCommitId "$CI_COMMIT_SHA" --gitBranch "${MULT_COMMIT_BRANCH}" --url "${CI_PIPELINE_URL}" --users "${GITLAB_USER_LOGIN}" --gitProjectPath "${CI_PROJECT_PATH}" --pipelineId "${CI_PIPELINE_ID}" --pipelineName "${CI_PROJECT_PATH}" --ciJobName "${CI_JOB_NAME}" 2>&1 >&3 3>&-); } 3>&1 || { if [[ $? == 255 ]]; then exit -1 # 阻断 else echo "failed security scan" fi } echo "Finish security scan"
然后在Gitlab Runner的配置中增加:
pre_clone_script="path/seccheck.sh"
这样就实现了我们的终极目的。
剩余问题解决
到目前为止,我们基本上完成了对于Gitlab 项目的强制检测和卡点功能,我们最终使用的方式是使用Gitlab Runner的pre_clone_script
配置。
但是这个配置存在一个问题,那就是每一个Job在执行前都会被调用。
这种重复调用明显不是必须的,我们预期的是在第一个Job进行完安全扫描后,后续的Job就不在进行安全扫描,该怎么办呢?
我们可以在Job与安全扫描前增加一个调度代理节点,实现功能是:先使用Gitlab Restful API获取当前Pipeline的所有Job列表,判断是不是第一个 Job (Job1),不是就不进行安全扫描。
这样我们就彻底解决了同一条流水线会进行多次安全扫描的问题。
如果您使用的Gitlab Runner模式是k8s,而不是shell,那么可以使用RUNNER_PRE_CLONE_SCRIPT
代替pre_clone_script
配置。
写在最后
针对使用Gitlab CI的项目接入代码安全扫描问题,以上循序渐进的提出了几种处理方式。
其实以上几种方式,本身都并无优劣之分,主要还是看具体业务场景,比如项目数量不多,最基础的接入方式也没问题;如果项目量非常大,又需要安全卡点,最后基于Gitlab Runner的方式肯定是最好的。
本文作者: l4yn3@小米安全
笔者专注于应用安全测试,代码审计,以及devsecops工具链设计,研发与业务整合工作。