1. 为什么我建议把审计逻辑前置到模板阶段
上个月我帮团队搭了一套Terraform模板的安全合规性自动化审计流水线,目标是让所有基础设施代码在合并之前先过一轮机器审计。以前我们的安全合规检查主要靠云控制台人工点选,模板改了没人记得同步基线;现在规则全部写成策略文件,跟着仓库走,扫出问题直接挡住合并,整个流程省掉至少一半的review工作量。如果你也在维护一套被多个环境复用的Terraform模板,或者被大量重复的“我看不出问题”评审折磨过,这篇文章应该能给你一个直接能抄的方案。
为什么会想搞这套东西?起因是环境上线前做安全自查,发现一条暴露公网的安全组规则,顺藤摸瓜查到它在三个环境的模板里都出现过。等我改完安全组,又发现另一个对象存储桶的ACL是公共读。问题不是单点失误,而是同一个错误被模板复制到了多个环境。Terraform模板的价值在于复用,但复用的另一面是:只要一个模块里埋了雷,所有引用它的环境一起踩。这种时候靠人肉review,很难每次都盯住细枝末节,所以我决定把审计规则交给自动化工具。
1.1 模板里最常出现的几类“定时炸弹”
结合我扫过的一批内部模板,高频问题集中在四个方向:
- 网络暴露面失控。安全组或防火墙规则直接放行
0.0.0.0/0,常见写法是cidr_blocks = ["0.0.0.0/0"],或者用了可变参数后默认值给成了全网段。还有管理端口22、3389、3306直接对公网开放。 - 数据保护缺位。对象存储桶开了公共读,数据库删保护参数没开,备份开关为false,加密默认走平台托管但模板里显式关闭。这些问题平时不影响功能,等到出事才意识到代价。
- 身份权限过宽。IAM策略里配了一堆
"Action": "*"和"Resource": "*",或者给实例绑定的角色权限远远超过运行需要。权限越大越难审计,出了问题也难以溯源。 - 敏感信息硬编码。模板变量里直接放访问密钥、数据库密码、第三方API Token。这些值一旦进到版本库,等于把钥匙挂在门口。
这些问题之所以反复出现,并不是团队不重视安全,而是大部分模板最初都是照着快速演示、临时调试或网上示例改的。示例代码追求“能跑”,不会替你考虑生产环境的安全基线。等模板被复制进正式环境,隐患就成了默认配置。
1.2 人工review的极限在哪里
我见过很多团队的安全评审,最后退化成了“看diff有没有明显奇怪的地方”。原因是Terraform模板经过模块化、变量化之后,人已经很难在脑袋里还原最终云资源的样子。一个网络模块可能有几十个子网、路由表、ACL规则,每条规则都要比对协议、端口、源地址,靠眼睛扫根本扫不过来。
更麻烦的是动态表达式。count、for_each、templatefile()这些能力让模板变得灵活,但也让最终配置不再是一行行静态文本。你看到一个s3_bucket的定义,它的acl可能来自变量,可能来自for循环,可能由某个布尔值拼出来。人工评审很难判断变量的所有取值组合下是否安全,而自动化工具可以做到每次改动都重新计算、重新扫描。
把审计前置到模板阶段,本质上是把“事后在控制台看风险报告”变成“事前在代码里卡住风险”。模板还没成型,机器已经告诉你哪些配置不符合基线。这比上线后补救便宜得多。
2. 自动化审计的核心逻辑与工具选型
2.1 先分清审计层次:语法、安全与策略
刚开始搭这套流水线时,我犯过一个典型错误:想找一个工具把所有问题都管了。后来发现不行,所谓“安全合规审计”至少要拆成三层。
第一层是语法与格式,解决的是模板能不能用、好不好维护。terraform validate负责任何语法错误,terraform fmt统一格式,tflint做类型检查和过时参数提醒。这一层不直接讲安全,但能避免很多低级问题。
第二层是安全扫描,解决的是资源配置是否存在已知风险。比如网络端口暴露、存储桶公共读、未加密磁盘、弱加密协议。代表工具是tfsec、checkov、terrascan,它们内置了大量规则集,能直接扫Terraform文件或plan输出。
第三层是策略即代码,解决的是组织自定义基线。安全扫描覆盖的是通用风险,但每个团队都有自己的底线:允许哪些region、命名规范、标签要求、是否禁止某类资源、环境要有生产/测试隔离标记。这些规则用OPA/Conftest写最合适。
三层工具各管一段,才能既覆盖通用问题,又不牺牲组织特殊要求。用一个工具硬刚所有场景,结果要么规则漏了一堆,要么误报多到没人看。
2.2 我最后留下的工具组合
实际落地时,我保留了下面这套组合:
| 工具 | 定位 | 我在流水线里用来解决什么 |
|---|---|---|
| terraform validate / fmt | 基础校验 | 保证模板语法正确、格式统一 |
| tflint | 静态检查 | 发现废弃参数、类型错误、不合理引用 |
| tfsec | 安全静态扫描 | 高危网络、权限、加密类风险,规则直观 |
| checkov | 多框架合规扫描 | 覆盖云平台上的常见安全基线,支持自定义策略 |
| OPA / Conftest | 策略即代码 | 组织级自定义规则,比如region白名单、强制标签 |
| jq | 结果处理 | 把多工具输出合并成一份审计报告 |
我没把terrascan放进主链路,但保留了它在夜间巡检中的位置。它和checkov能力有重叠,主链路放两个同类扫描器会增加噪音。tfsec虽然维护节奏变慢,Scott很多规则已经并入Trivy,但独立二进制仍然能用,规则集成熟,输出格式干净,适合做MR阶段的第一道卡点。如果你不想引入太多工具,直接用Trivy的配置扫描替代tfsec也完全可以。
2.3 模板字符串和动态表达式是审计的第一个难点
接入工具后遇到的第一个实际问题,是模板中大量使用动态表达式导致扫描器“算不出”结果。一个很常见的场景是安全组规则端口从变量数组读取:
resource "aws_security_group_rule" "allow_app" { type = "ingress" from_port = var.app_ports[count.index] to_port = var.app_ports[count.index] cidr_blocks = var.allowed_cidrs count = length(var.app_ports) }变量值在模板文件里没有直接体现,静态扫描器只能通过默认值或变量类型推断,扫出来的结论经常会漏。templatefile()函数也存在类似问题,外部模板字符串里的${...}占位符,在HCL文件里根本看不到真实填充结果。
我的处理思路是三层配合:先让工具根据变量声明和默认值做静态评估;再在流水线里用具体环境的.tfvars生成一次terraform plan,对计划结果做安全扫描,因为plan已经包含了变量解析后的真实资源;最后要求模块作者对高风险参数显式声明,而不是依赖隐式默认值。比如s3_bucket的acl如果非特殊场景必须显式写成private,不允许空着让工具猜。这样合伙人写模板时有了约束,审计结果也更稳定。
3. 构建一套可落地的Terraform模板安全合规审计流水线
3.1 落地前先把目录和基线准备好
动工之前先把仓库结构理清楚,否则后面工具会扫到一堆不想扫的东西。我建议至少包含四块内容:
modules/:通用模块,比如网络、计算、存储模块;environments/:按环境拆分的目录,里面是模块的调用配置;policies/:存放OPA/Conftest策略文件和checkov自定义策略;scripts/:放统一调用的审计脚本,保证本地和CI行为一致。
基线文件也一样重要。我的做法是维护一个baseline.json,里面记录“当前允许存在的存量风险”。第一次全量扫描出来的高风险项不可能一天清完,直接设成阻断会让团队崩溃,所以先把已知问题写进基线,允许临时通过,但要求限期修复。这样流水线既能上线,又不会立刻把所有人卡死。
3.2 接入tfsec:一条命令扫出高危项
tfsec接入非常快,安装后直接在目录下跑:
tfsec . --format sarif --out tfsec.sarif想要更直观的终端输出也可以:
tfsec ./modules ./environments --concisetfsec会给出严重级别、规则ID和建议。比如一条对象存储公共读规则,它会告诉你aws-s3-block-public-acls对应的问题,以及应该改成什么配置。我们最关心的就是CRITICAL和HIGH两类结果,MEDIUM和LOW进入报告但不阻断合并。
实际使用中,我建议给tfsec配置自定义规则或排除规则。初始阶段误报肯定有,比如内部网络模块中用到的管理端口虽然暴露给部分内网网段,但扫描器因为看不到整个网络拓扑,会判断成高风险管理端口。对这类情况,先加tfsec:ignore:aws-ec2-no-public-ingress-sgr这类注释并写清原因,比直接关掉整个规则合适。原因是注释能保留审计痕迹,后续做基线复核时还能看到为什么放行。
3.3 接入checkov:覆盖云厂商安全基线
checkov支持Terraform、CloudFormation、Kubernetes等多种框架,覆盖面广。我的主命令长这样:
checkov -d . --framework terraform \ --skip-check CKV_AWS_18,CKV_AWS_23 \ --output sarif --output-file-path checkov.sarif第一次跑的时候,checkov会报告不少IAM、存储、日志类问题。它的规则很细,比如S3桶访问日志是否开启、ECS任务是否定义了内存限制、RDS实例是否开启了删除保护。其中有些规则未必适合你的场景,直接跳过即可。但跳过的规则建议写进脚本注释,别无声无息地关。
checkov还有一个值得用的能力是自定义策略。团队如果规定所有生产资源都必须打environment标签,可以写一个简单的策略文件放进policies/目录,然后用--custom-policy-dir加载。这样checkov不只是扫通用基线,也帮你执行组织规范。
3.4 用OPA写一条自定义存储桶策略
比多个云资源检查更灵活的是OPA。OPA处理的是结构化数据,你需要先让terraform输出一个JSON格式的计划:
terraform init terraform plan -out plan.tfplan terraform show -json plan.tfplan > plan.json接着写一条Rego策略,检查所有变更资源里有没有公共读写的存储桶:
package main import future.keywords deny[msg] { change := input.resource_changes[_] change.type == "aws_s3_bucket" change.change.after.acl == "public-read" msg := sprintf("存储桶 %s 被设置成公共读,请改为 private", [change.address]) }然后执行:
opa eval --format pretty --data policies/bucket_acl.rego --input plan.json "data.main.deny"有输出就代表plan中存在违规项。这套流程最大的价值是:策略可以写成本地代码评审的普通PR,谁想改安全基线,得先过一轮代码评审,改的是OPA规则而不是某个控制台开关。审计规则本身也纳入了版本管理和审计,形成闭环。
我推荐至少写三条自定义策略起步:强制生产资源打标签、禁止使用全局管理权限角色、限制允许创建的云服务商region。这三条对任何团队都有普适性,写起来也不复杂,能够很快验证策略框架是否跑得通。
4. 把审计结果接入CI/CD门禁
4.1 阶段的卡点设计
工具装好之后,怎么卡点比怎么扫描更重要。卡得太早,开发抱怨流程重;卡得太晚,问题成本高。我实际落地的卡点分三个阶段:
| 阶段 | 目标 | 执行内容 | 失败策略 |
|---|---|---|---|
| MR阶段 | 快速拦截明显问题 | 对变更目录跑terraform fmt、tflint、tfsec | 高危结果阻断合并 |
| 发布阶段 | 确认最终计划合规 | 生成plan.json,跑checkov和OPA | 有阻断项则禁止apply |
| 夜间巡检 | 处理存量与漂移 | 全量扫描所有模板,更新基线 | 只告警,不阻断 |
MR阶段做增量扫描,只扫git diff --name-only涉及的目录,速度很快,开发体验好。发布阶段是最终防线,必须拿真实变量生成plan再扫,因为MR阶段可能因为变量默认值和真实环境不一致而漏报。夜间巡检则是兜底,用来防止有人绕过流水线手工apply或基线持续劣化。
对于增量扫描,我用了这样一个简化逻辑:
changed_dirs=$(git diff --name-only origin/main...HEAD | grep -E '(modules|environments)/.*\.tf$' | xargs -n1 dirname | sort -u) for dir in $changed_dirs; do tfsec "$dir" --format json > tfsec_$dir.json || true done注意|| true不能随便去掉,因为工具返回非零退出码时,要先收集所有结果再统一判断,不能被第一个错误打断整体流程。
4.2 门禁阈值与修复闭环
刚开始设置阈值,我踩过一个坑:把所有warning级别都设成阻断,结果团队每天被海量低优先级问题淹没,最后反而学会用tfsec:ignore和checkov的--skip-check绕过门禁。后来改成只阻断CRITICAL和HIGH,并且给每个HIGH问题绑定一个负责人和修复期限,开发体验和修复率反而都好了。
修复闭环同样重要。门禁只负责发现问题,如果阻塞了PR却没有跟进机制,问题会挂几天没人动。我的做法是:CI扫描结束后,用脚本把失败结果自动评论到MR里,包括规则ID、文件行号、修改建议。开发者在MR页面就能看到具体问题,顺手改掉再提交,不用去翻流水线日志。
高优问题修复之后,还要有人工复核环节。机器判断的是“配置是否符合规则”,人需要判断的是“这个规则是否还适用于当前业务”。比如某个存储桶确实需要对外提供静态资源,那公共读可能改成通过CDN加源站访问,而不是简单改成private了事。
4.3 流水线本身的安全和性能问题
集成流水线时还要注意一个反直觉的点:审计流水线使用到的云凭证必须最小化。很多团队图省事,把具有管理员权限的ak/sk直接放到CI变量里,这等于把保险柜钥匙交给了一个每天跑陌生人代码的进程。我的建议是给CI创建一个只读角色,仅允许ec2:Describe*、s3:GetObject这类只读操作,保证terraform plan能正常执行即可。
性能方面也有优化空间。terraform init每次全量拉provider模块会很慢,建议利用CI的缓存机制缓存.terraform目录,并且设置-backend=false或远程状态只读配置,避免流水线误改状态文件。并发扫描多个目录时,注意tfsec和checkov都比较吃内存,自建runner要给足资源;用云托管CI则要注意并发任务数限制,避免排队。
另外,所有扫描结果最好统一转换成SARIF或JSON格式,上传到制品库或直接存进流水线的artifacts。有了历史结果,后面才能做趋势分析,比如这次解决了多少高危项、新增了多少低危项,这些数据对推动改进非常有用。
5. 审计过程中的典型问题与排查思路
我整理了在搭建和运行这套审计流水线时实际遇到的几个问题,供你对照排查:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| tfsec对自定义模块报错多 | 模块内部缺少资源类型识别,或使用了动态block | 给模块加tfsec自定义规则,或用计划扫描代替目录扫描 |
| checkov扫出大量重复项 | 同一目录被多次递归扫描 | 明确指定扫描根目录,启用--compact显示 |
| OPA读取plan.json报错 | plan JSON层级太深,或资源字段缺失 | 先用jq检查关键字段路径,再写Rego |
| 变量默认值导致误报漏报 | 工具无法解析所有变量组合 | 在流水线里用.tfvars生成真实plan再扫 |
| 门禁阻断后团队绕过扫描 | 阈值设置不合理、修复流程太重 | 只阻断高危项,同时保证修复链路足够短 |
| 本地能过但CI不过 | 本地没跑terraform init或变量文件不同 | 统一走scripts入口,保证本地命令和CI完全一致 |
先说tfsec误报的问题。tfsec对常用云资源支持好,但遇到自定义模块它会根据模块内部的资源定义来扫描,如果模块里用了很多动态参数,结果就不太准。这种情况不要急着关规则,先去查tfsec的docs,看能不能通过custom checks覆盖。实在不行再忽略,并写清理由。
OPA解析失败是我花时间最多的部分。terraform show -json输出的结构非常深,Rego里一个路径写错,结果就是一组空集合,工具不会明确告诉你“你这个路径不存在”。排查时先用jq确认节点路径,比如:
jq '.resource_changes[] | select(.type=="aws_s3_bucket") | .change.after' plan.json把真实字段结构打印出来,再照着写Rego,基本不会跑偏。
还有一个容易被忽视的问题是CI和本地环境不一致。开发者在macOS上跑通了,CI里却因为terraform版本、provider版本、变量文件路径不同导致结果不一样。我从一开始就把扫描命令封装成scripts/audit.sh,本地和CI都只调用同一个脚本,参数统一传,差异就只来自环境和数据,排查起来清晰很多。
6. 踩过几次坑之后的体会
这套Terraform模板安全合规性自动化审计流水线跑了一个月之后,我最大的体会是:机器扫的是配置,人审的是风险上下文。静态扫描能拦住低级的、通用的问题,但真正复杂的攻击路径、业务边界、跨资源影响,还是需要人来判断。自动化不是替代安全评审,而是把评审者的注意力集中到真正需要思考的问题上。
落地过程中的一个重要经验是增量落地,别追求一步到位。我们第一周只卡了CRITICAL和HIGH两类问题,同时建立基线白名单;第二周开始加自定义OPA策略;第三周才把夜间巡检跑起来。如果第一天就想把所有风险清零,大概率会把团队信心打没了。
另外一个让我印象很深的事情是:模板审计结果不是唯一的证据来源。模板层的静态扫描再完善,也覆盖不了环境被手工改动后的漂移。所以我后续迭代的方向,是把模板审计结果和云环境实态扫描打通,用资源属性和模板定义做diff,及时发现那些绕过了流水线的变更。到那时,模板定义、审计规则、实态检查三份数据对得上,基础设施的合规状态才算真正可信。