当前位置:首页 >  聚焦  > 正文

GitLab CI接入代码安全扫描技术实践|全球微速讯

时间:2023-06-13 22:09:53     来源:互联网

在诸多的互联网企业中,私有化部署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 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:- mvntest# 执行命令:运行单元测试deploy_job:# 定义一个部署任务stage: deploy# 指定所属阶段script:- ./deploy.sh# 执行命令:调用脚本部署应用程序only:- master# 仅在 master 分支提交时执行

当我们在提交项目代码的时候,GitLab会自动运行根目录下的.gitlab-ci.yml配置文件,执行里面的指令。

GitLab CI最核心的是2个部分:stagejob

前面有提到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: secscanscript:-export MULT_COMMIT_BRANCH=${CI_COMMIT_BRANCH}-if [ !"$MULT_COMMIT_BRANCH" ];thenexport MULT_COMMIT_BRANCH=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME};fi-if [ !"$MULT_COMMIT_BRANCH" ];thenexport 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: secscanscript:- I amdone!-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/commom/gitlab_ci_template/.base_gitlab_ci.yml

secscan-job:stage: secscanscript:-export MULT_COMMIT_BRANCH=${CI_COMMIT_BRANCH}-if [ !"$MULT_COMMIT_BRANCH" ];thenexport MULT_COMMIT_BRANCH=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME};fi-if [ !"$MULT_COMMIT_BRANCH" ];thenexport 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:"commom/gitlab_ci_template"# 项目名称ref: master# 分支file:"commom/gitlab_ci_template/.base_gitlab_ci.yml"# 公共配置文件stages:# 定义多个阶段- build# 构建-test# 测试- secscan# 安全扫描节点- deploy# 部署build_job:# 定义一个构建任务stage: build# 指定所属阶段script:- mvn package# 执行命令:构建应用程序test_job:# 定义一个测试任务stage:test# 指定所属阶段script:- mvntest# 执行命令:运行单元测试deploy_job:# 定义一个部署任务stage: deploy# 指定所属阶段script:- ./deploy.sh# 执行命令:调用脚本部署应用程序only:- master# 仅在 master 分支提交时执行

这样就解决问题啦,我们可以让研发统一按照这个模板接入,如果后续安全扫描节点有变更,我们更改commom/gitlab_ci_template项目就可以啦!

不过你有没有发现问题,我们的commom/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. Use stage 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:

.pre

build

test

deploy

.post

如果项目并没有在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: .prescript:-export MULT_COMMIT_BRANCH=${CI_COMMIT_BRANCH}-if [ !"$MULT_COMMIT_BRANCH" ];thenexport MULT_COMMIT_BRANCH=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME};fi-if [ !"$MULT_COMMIT_BRANCH" ];thenexport 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}

提交代码执行一下看看,.preStage 被执行,我们的安全扫描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 ="ASw-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}" --timeLimit"120" --ciJobName"${CI_JOB_NAME}" 2>&1 >&3 3>&-); } 3>&1 || {if [[ $? == 255 ]];thenexit -1# 阻断elseecho"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的方式肯定是最好的。

标签:

推荐文章