用tfsec和Terratest为Terraform代码进行检查的教程

376 阅读16分钟

france landmark water clouds

利用tfsec和Terratest提升你的Terraform的等级

简介

自动化的安全和合规性测试正在成为软件开发中越来越普遍的一部分。如果不这样做,管理现代软件项目中数量庞大的依赖关系就变得不切实际。

云平台的变化速度意味着基础设施即代码(IaC)项目也应该进行这种检查。幸运的是,现在有一些专门针对IaC的工具可用于这项工作。

在这篇文章中,我将展示如何使用tfsec对你的Terraform代码进行安全检查,并解决它发现的任何问题。此外,我们还将使用TerratestGo库,以确保我们不会意外地造成任何功能退步。我将使用一个示例项目来演示。

要求和假设

要跟上这里的进度,你应该对以下内容有基本了解。

  • Terraform
  • AWS
  • 高朗

我不会介绍如何从CLI连接到AWS,也不会介绍Terraform AWS提供商如何整合它来计划/应用/销毁基础设施堆栈。

演示项目

这个演示项目是非常基本的,而且在很大程度上适合生产。然而,对于我们的演示目的来说,它已经足够了。它是一个网络服务,做两件事。

  1. 接受带有图像元数据的POST 请求,以及
  2. 返回我们数据库中所有图像元数据的列表

在存储方面,该服务将数据行存储在一个SQLite数据库中,该数据库与该服务保存在同一个EC2实例中。服务器还运行Litestream,将SQLite数据库流向AWS S3,因此在实例故障或刷新的情况下,数据库可以被恢复。

该项目代码包括两个主要部分:服务本身的代码,以及建立托管该服务的基础设施的代码。

服务代码

服务代码包含以下文件。

  • main.go:网络服务的代码
  • demoservice.service:用于运行我们服务的systemd服务文件
  • createTable.sql:一个SQL文件,用于在SQLite数据库中创建一个表。

基础设施代码

基础设施代码被分成三个主要目录。

  • application:为我们的服务创建基础设施并部署我们的服务二进制。我们的litestream.yml 配置文件也在这里。
  • test:我们的Terratest文件
  • account:创建所需的VPC、子网和互联网网关。在这篇文章中,我不会过多地关注这个问题。

你还会注意到,我们有多个版本的applicationtest 文件夹,代表它们是如何随着我们重构代码而发展的。

在最初的application 文件夹中,我们创建了以下组件。

  1. 一个EC2实例,包括user_data ,用于在启动时安装和配置我们的服务(更多信息见下文)
  2. 一个网络接口和弹性IP,为该实例提供公共IP地址
  3. 一个连接到网络接口的安全组,用于控制到我们实例的流量
  4. 一个IAM策略和角色,提供我们实例所需的权限
  5. 一个S3桶,用于存储我们的应用程序、配置文件和Litestream备份。

在EC2实例上安装服务的一般流程(通过user_data )是。

  1. 在实例上安装SQLite和AWS CLI
  2. 下载并安装Litestream
  3. 从S3下载并安装我们的演示服务
  4. 检查S3中是否存在一个dbcreated.flag 文件。如果不存在,那么在本地创建一个新的SQLlite数据库,并在S3中创建标志
    5)启用并启动Litestream和我们的服务

重申我在本节开始时提出的观点:这个应用程序没有反映企业服务的最佳实践,例如,没有高可用性或扩展的规定。然而,对于我们在这篇文章中所涉及的内容来说,这已经足够了。

我们的初始测试

让我们看一下我们的初始Terratest文件。首先,我们需要确保我们的基础设施名称是唯一的。在常规使用中,我们通过在堆栈创建时传入的环境变量来做到这一点。对于我们的测试,我们将生成一个随机字符串。

serviceEnvironment := strings.ToLower(random.UniqueId())

并把它作为创建堆栈时传入的一系列选项的一部分。

terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
	// Set the path to the Terraform code that will be tested.
	TerraformDir: "../application",
	Vars: map[string]interface{}{
		"environment": serviceEnvironment,
		"vpc_name": "terraform-tools-demo-dev",
		"subnet_name": "terraform-tools-demo-dev-subnet",
		"stack_name": "terraform-tools-demo",
		"aws_region": awsRegion,
	},
})

注意,我们正在硬编码一个子网和VPC名称。这将是之前由account 文件夹中的代码创建的。如果你愿意,你可以在每次测试时创建VPC和子网。但是在这种情况下,它超出了我们要测试的范围,所以我们将坚持使用硬编码的值。

接下来,我们使用Golang的 defer关键字来定义几个步骤,以确保在我们的测试完成后(或在出现意外退出的情况下)发生。

defer 是一个后进先出的关键字,所以在这种情况下,我们的测试代码将首先清空我们的S3桶,然后摧毁由测试创建的基础设施。

// Clean up resources with "terraform destroy" at the end of the test.
defer terraform.Destroy(t, terraformOptions)

// Run "terraform init" and "terraform apply". Fail the test if there are any errors.
terraform.InitAndApply(t, terraformOptions)

// Empty and cleanup bucket
s3Bucket := terraform.Output(t, terraformOptions, "service_bucket")
defer aws.EmptyS3Bucket(t, awsRegion, s3Bucket)

如果你是Terratest的新手,有几点值得理解:

  • terraform.InitAndApply 接受我们定义的选项并创建我们的堆栈
  • terraform.Output 检索我们的Terraform代码中定义为输出的任何选项

接下来,我们将设置一些变量:

publicIp := terraform.Output(t, terraformOptions, "instance_public_ip")
url := fmt.Sprintf("http://%s:8080/images", publicIp)
testBody := "{\"FileName\": \"test.png\",\"Description\": \"This is my test image\"}"

Where:

  • publicIp 将包含连接到我们实例的弹性IP的公共IP地址
  • url 使用这个公共地址来形成我们将发送HTTP请求的URL
  • testBody 包含原始的JSON字符串,我们将 到我们的服务,在我们的数据库中创建一个行。POST

现在我们有了向我们的服务发送POST请求所需的一切:

http_helper.HTTPDoWithRetry(t, 
  "POST", 
  url, 
  []byte(testBody), 
  map[string]string{"Content-Type": "application/json"}, 
  200, 
  30, 
  5*time.Second, 
  nil
)

其中:

  • t 是传递给Golang测试方法的一个标准测试参数(t *testing.T)。它管理着我们测试执行的状态。
  • "POST" 是我们将使用的HTTP方法
  • url 是我们将发送请求的URL
  • []byte(testBody) 是我们的原始JSON字符串,铸成一个字节数组
  • map[string]string{"Content-Type": "application/json"} 是一个包含我们所需头信息的地图
  • 200 是预期的HTTP响应(对于一个成功的测试)。
  • 30 是测试失败前尝试 的次数。POST
  • 5*time.Second 是发送请求之间的等待次数(5秒)。
  • nil 代表我们的TLS配置,在这种情况下是未定义的,因为我们正在使用HTTP。

因此,从本质上讲,这个测试将尝试发送多达30次的请求,间隔时间为5秒。只要它收到一个200的HTTP响应,测试就会通过。

下一个测试将向我们的API发送一个GET 请求。然后我们将检查预期的项目数量是否被返回,以及它们的值是否正确。这将确保我们之前的POST 请求导致正确的值被存储在数据库中。

我不会把所有的代码都写在这里,因为我们如何把JSON解密成地图的细节与这篇文章并不相关(如果你有兴趣,欢迎你看一下测试源)。然而,有一个重要的部分值得理解。

// Get our image metadata
_, body := http_helper.HttpGet(t, url, nil)

这个方法向API发送了一个HTTPGET 请求。它返回两个值:statusCodebody 。我们在这里并不关心statusCode ,所以我们把它分配给一个未使用的_ 值,从而忽略它。我们的响应被存储在body 变量中。

从这里开始,我们使用json.Unmarshal() 方法将原始响应存储在一个叫做mp 的地图变量中。这是一个地图数组(这就是我们的API返回的内容)。

我们现在可以检查我们的数组是否包含我们在第一个测试中发布的值:

// Get number of elements in our JSON array response
numElements := len(mp)
if (numElements != 1) {
	t.Logf("Expected 1 element returned, got: %d", numElements)
	t.FailNow()
}

注意,我们期望在我们的JSON响应中只有一个元素,如果我们得到其他的东西,我们将记录下来,并立即失败我们的测试。

最后,如果这个测试通过了,那么我们将确保响应中的"FileName""Description" 字段与我们所期望的一致。

// Make sure the values coming back in the response are correct
fileName := mp[0]["FileName"]
description := mp[0]["Description"]

assert.Equal(t, "test.png", fileName)
assert.Equal(t, "This is my test image", description)

现在让我们运行我们的测试,看看Terratest的输出是什么样子的。要做到这一点,我们在终端中切换到infra/test 目录并运行。

> go test

我不会把所有的输出包括在这里,因为它是非常冗长的。不过,它看起来会是这样的:

首先,我们的Terraforminitapply 完成了。我们可以看到13个资源已经被创建,并且我们的两个输出已经被返回。请注意随机的环境标识符--在这种情况下gf8ole

接下来,一旦我们的基础设施被创建,我们将看到在我们的实例被启动时,发生了多个失败的 POST 请求。最后,如果/当服务真正启动时,我们将看到一个POSTGET 的请求在快速连续发生。

假设没有立即出现故障,我们的S3桶就会被清空,我们的堆栈也会被销毁。

在我们的测试运行结束时,我们会得到一份关于测试情况的简短报告。

在这种情况下,他们都通过了,所以我们可以确信,我们的应用程序正在按照预期工作

现在我们有了一种方法,可以轻松地验证我们可能做出的任何进一步的改变不会破坏任何东西,让我们继续使用tfsec来检查我们的Terraform代码从安全角度来看有多好。

首次运行tfsec

要对我们代码的初始迭代运行tfsec,我们可以简单地改变到infra/application 目录并运行。

> tfsec

输出将看起来像这样:

你可以看到里面有一些红色的文字(这绝不是一件好事)和一个提示我们有十八个潜在问题的摘要

修复低垂的果实

让我们从一些比较容易解决的问题开始,而不是一下子就去解决所有的问题,从这个严重程度的结果开始。

来自公共互联网的入侵

一般来说,在生产场景中,你永远不会像这样直接暴露一个实例。然而,在我们的演示中,我们故意允许公共互联网访问该实例,并愿意接受这种风险。那么我们如何告诉tfsec忽略这个问题呢?

tfsec提供了一个简单的基于评论的机制来做到这一点。为了激活它,我们在违反规则的那行有问题的代码上附加一个注释。在这种情况下,注释将出现在代码中,如下所示。

注意注释的形式是:#tfsec:ignore:<rule> ,其中<rule> 是我们想要抑制的规则的名称。

公共S3桶

我们要研究的tfsec的下一个结果与我们的S3桶有关。

请注意,这个结果包括一个有用的 "更多信息 "部分。在这种情况下,该部分包含tfsec规则细节和相关Terraform文档的链接。这使我们不必自己去挖掘文档。

这个结果与S3桶级配置有关。事实证明,在我们的结果中,实际上还有其他三个类似的相关问题:

Result #7 HIGH No public access block so not blocking public acls
Result #8 HIGH No public access block so not blocking public policies
Result #11 HIGH No public access block so not restricting public buckets

在这些情况下,我们绝对没有必要进行公开访问,所以让我们继续下去,把它收紧,一举两得:

未加密的数据

另一个高优先级的结果是:

Result #9 HIGH Bucket does not have encryption enabled

我们以后会更详细地讨论这个问题,但现在我们只需启用服务器端对我们的S3桶进行加密,使用我们默认的KMS密钥。

同样的,我们的实例根磁盘也没有加密,所以我们也会启用它。

在没有令牌的情况下访问IMDS

接下来,我们看到一个与实例元数据服务有关的结果。

Result #13 HIGH Instance does not require IMDS access to require a token

这正是基础设施工程师可能不小心忽略的问题。IMDS(实例元数据服务)是一个对所有实例可用的服务,允许用户或管理员获得有关实例的信息。它对实例在使用IAM角色时获得临时凭证的过程也很关键。

在发生了一些引人注目的事件后,AWS推出了这项服务的新版本,在这些事件中,配置错误或有漏洞的应用程序无意中向攻击者暴露了这些临时凭证。新版本的缓解措施之一是要求应用程序在向服务提出请求之前获得一个临时会话令牌,从而将意外暴露的风险降到最低。这可能是对tfsec规则文档的最好解释。

IMDS v2(实例元数据服务)引入了会话认证令牌,提高了与IMDS对话时的安全性。默认情况下,aws_instance 资源将IMDS会话认证令牌设置为可选。为了完全保护IMDS,你需要通过使用metadata_options 块和其http_tokens 变量设置为required 来启用会话令牌。

为了纠正这个问题,我们更新了我们的实例配置,如下所示。

测试我们的变化

你可以在这里看到包含到目前为止的变化的代码版本。我还解决了其他一些比较小的问题,但为了简洁起见,我不会在这篇文章中描述它们。

当我们再次运行tfsec时,我们可以看到我们只剩下3个潜在的问题了。

然而,在继续之前,最好先确定我们没有意外地破坏任何东西。通常情况下,我们必须通过应用我们的变化,手动测试应用程序,并在实例日志中挖掘任何可疑的东西来做到这一点。然而,由于我们使用的是Terratest,我们可以再次运行go test 。在这种情况下,结果是。

看起来一切都通过了!我们现在可以放心地继续修复剩余的问题。

解决IAM策略问题

在剩下的三个结果中,#1和#2是相似的。

HIGH IAM policy document uses wildcarded action 's3:*'

IAM策略是出了名的难搞。人们经常使用通配符规则来使事情顺利进行。虽然这在短期内可以节省时间,但从长远来看,如果他们不花时间回过头来收紧东西,也会使他们面临严重问题。

在这些情况下,tfsec告诉我们,如果我们的应用程序或实例被破坏,那么攻击者可以对我们所有的S3桶做任何他们想做的事情。

让我们试着将我们的访问限制在我们的应用程序桶,并锁定这些权限。

问题解决了吧?好吧,为了100%确定,让我们快速运行我们的测试,确保我们的应用程序的更新版本仍在工作。

哦,不!看起来我们的改变破坏了我们的应用程序!我们的应用程序被破坏了。

如果我们仔细看一下策略,我们可以看到问题所在。我们指定了行动s3:GetObjects ,但实际上它应该是s3:GetObject 。这是一个容易犯的错误,但幸运的是,它也很容易解决。然后,让我们再一次在更新的应用程序版本上运行Terratest。

看起来,这已经解决了问题,我们又开始工作了。

水桶加密,第二次尝试

现在让我们看一下tfsec的第三个也是最后一个结果。

Result #3 HIGH Bucket does not encrypt data with a customer-managed key.

看起来tfsec对我们处理先前关于未加密数据的问题的方式有问题。具体来说,这个规则不是使用账户中的默认密钥,而是建议我们创建一个KMS密钥作为我们堆栈的一部分,并使用它来加密我们的桶的内容。让我们这样做,并更新我们的水桶加密配置。

最后一件事

在转向使用新的KMS密钥之后,问题来了:我们的测试是否真的能够测试这一变化?仔细想想,我们的数据库创建和Litestream备份过程到目前为止还没有被测试所覆盖。然而,它们也需要对S3的写入权限,以及KMS的权限,以使用我们的新密钥来加密对象。我所做的一切是否真的可行?

为了弄清楚,让我们创建一个新版本的测试

这个新的检查只是确保我们的dbcreated.flag 文件是存在的。对于我们的目的,这足以确认我们的实例有权限写入我们的S3桶。

有趣的是,当我们运行更新的测试时,我们得到

似乎我们的IAM权限变化破坏了S3对我们实例的写入权限。幸运的是,我们记得为它写了一个测试!

为了解决这个问题,首先我们要允许这个实例在我们的桶中获取、放置和删除对象。

同时,我们还需要为我们的密钥添加KMS权限,因为我们现在没有使用默认权限。

我们应用程序的更新版本上再次运行测试,我们得到

看起来我们已经把它恢复到了一个工作状态!现在,如果我们最后一次运行tfsec,我们看到

没有发现问题!这就更像了!

结论

在这篇文章中,我介绍了两个工具,它们将一般软件开发的实践带到了基础设施即代码的世界中。

我使用tfsec进行静态分析,发现了即使是有经验的工程师也很难在人工审查中注意到的安全问题。tfsec也被证明是了解Terraform最佳实践的绝佳方式。

然后,我使用Terratest来确保我对tfsec发现的问题的修复不会导致任何功能退步。我可以看到Terratest所支持的迭代、测试驱动的开发方法如何帮助我在未来自信地重构和/或实施新的变化。

很高兴看到我们在软件开发领域的其他部分认为理所当然的技术现在也可以应用于基础设施。我鼓励你通过接受它们来提高你的基础设施即代码的实践水平。