明明标了 sensitive,状态文件里却留着明文密码
目标
亲自找出工具生成的密码在状态、JSON 输出和已保存的计划中的哪些位置以明文残留,把 OpenTofu 的状态与计划加密从现有的明文状态迁移过来并强制执行,然后构建一种把密钥放在状态之外的配置。
为什么重要
sensitive 只是在人看的界面上遮住值的标记,工具保存的记录里仍然原样包含这个值。状态文件和 CI 上传的计划文件,实际上就成了密钥存储,不知道这一点,就会抱着“遮住了所以安全”的错觉,把状态随便放在任何地方。防范的办法有三层:存储时加密(密钥就成了状态的钥匙)、强制禁止退回明文,以及从一开始就不把值交给工具、只传递引用的设计。
步骤
- 在
/root/iac-secret/main.tf中放置random_password.db(长度 20,无特殊字符)、把它的值以password=<값>(占位符为密码值)写入out/db.conf的local_sensitive_file.dbconf,以及sensitive = true的输出db_password。执行 init·apply 之后,确认tofu output中的值是被遮住显示的。 - 找出状态文件中所有包含密码值的位置,把 JSON 路径按每行一个写入
/root/iac-secret/leak.txt。路径的格式是用点把键和数组序号连起来(例如resources.0.instances.0.attributes.x)。评分器会对通过tofu state pull获得的状态做同样的计算并进行比较。 - 把
tofu output的输出保存到/root/iac-secret/masked.txt。然后用tofu plan -out=/root/iac-secret/leak.tfplan保存计划,并统计计划文件(zip)内的tfstate条目中密码出现了多少次,用planleak=<수>(占位符为数量)写入/root/iac-secret/planleak.txt。 - 在
/root/iac-secret/encryption.tf中声明敏感变量state_passphrase以及状态·计划加密:pbkdf2 密钥提供者(口令就是该变量)、aes_gcm 方法,并把用于读取现有明文的 unencrypted 方法设为 state·plan 的 fallback。口令为labhub-state-passphrase-01,通过环境变量TF_VAR_state_passphrase传入。apply 之后,terraform.tfstate 必须是加密的(文件中没有密码,而有 encrypted_data)。 - 在
encryption.tf中删除 unencrypted 方法和两个 fallback,并在 state·plan 中设置enforced = true。使用正确的口令时,计划必须是干净的。用错误的口令(TF_VAR_state_passphrase=wrong-passphrase-000000)运行tofu plan,把输出保存到/root/iac-secret/wrongkey.txt。评分器会在副本中确认用错误的密钥读取确实会失败。 - 用正确的口令用
tofu plan -out=/root/iac-secret/safe.tfplan保存计划。这个文件中不得有明文密码,也不能是 zip。确认有密钥时tofu show -json safe.tfplan能够执行,没有密钥时会失败。 - 用
openssl rand -hex 16在/root/iac-secret/ref/secret/db.key中生成密钥,并把权限设为 600(在工具之外生成的密钥)。在/root/iac-secret/ref/main.tf中不放 random_password,只放向out/db.conf写入一行password_file=<그 경로>(占位符为该路径)的local_file.dbconf,然后执行 init·apply。评分器会检查 ref 的状态中任何位置都没有 db.key 的值,只有路径。
参考
- Pod 中有 OpenTofu 1.9.0 和 local·random provider 镜像源,无需互联网即可运行。从第 4 步开始,在每个 shell 中先执行
export TF_VAR_state_passphrase='labhub-state-passphrase-01'。 - 开启加密之后,
tofu state pull会显示解密后的状态。直接打开文件,只能看到 encrypted_data。 - 完全不把值留在状态和计划中的 ephemeral 资源和 write-only 属性,是在 OpenTofu 1.11 中引入的。这个 Pod 的 1.9.0 中没有,所以改用引用方式。
- 常见错误:在第 4 步不设 fallback 就开启加密,导致无法读取现有的明文状态。常见错误:把口令写在 main.tf 的默认值中并提交到仓库。
- Sensitive Data in State · State and Plan Encryption · Ephemerality · Terraform: Sensitive data
生成密码,并把输出遮住
在 /root/iac-secret/main.tf 中放置 random_password.db(长度 20,无特殊字符)、把它的值以 password=<값>(占位符为密码值)写入 out/db.conf 的 local_sensitive_file.dbconf,以及 sensitive = true 的输出 db_password。执行 init·apply 之后,确认 tofu output 中的值是被遮住显示的。
random_password 的 result 会被 provider 标记为敏感值。要把这个值作为输出导出,输出上也必须加上 sensitive,计划才能通过。local_sensitive_file 只是不在界面上显示内容,写入文件的方式是完全一样的。
被遮住的密钥在状态文件的哪些位置
找出状态文件中所有包含密码值的位置,把 JSON 路径按每行一个写入 /root/iac-secret/leak.txt。路径的格式是用点把键和数组序号连起来(例如 resources.0.instances.0.attributes.x)。评分器会对通过 tofu state pull 获得的状态做同样的计算并进行比较。
可以用 tofu output -raw db_password 获得该值(这也证明了遮蔽只是显示方式)。用 jq 的 paths(scalars) 和 getpath 可以找出所有值相同的位置。不止一处。
遮蔽只在界面上有效——JSON 输出和已保存的计划
把 tofu output 的输出保存到 /root/iac-secret/masked.txt。然后用 tofu plan -out=/root/iac-secret/leak.tfplan 保存计划,并统计计划文件(zip)内的 tfstate 条目中密码出现了多少次,用 planleak=<수>(占位符为数量)写入 /root/iac-secret/planleak.txt。
已保存的计划文件是 zip,里面包含生成计划时的状态副本。用 unzip -l 查看条目,再用 unzip -p <파일> tfstate(占位符为文件)取出来统计。如果 CI 把计划文件作为制品上传,那个制品也就成了密钥。
把明文状态迁移为加密状态
在 /root/iac-secret/encryption.tf 中声明敏感变量 state_passphrase 以及状态·计划加密:pbkdf2 密钥提供者(口令就是该变量)、aes_gcm 方法,并把用于读取现有明文的 unencrypted 方法设为 state·plan 的 fallback。口令为 labhub-state-passphrase-01,通过环境变量 TF_VAR_state_passphrase 传入。apply 之后,terraform.tfstate 必须是加密的(文件中没有密码,而有 encrypted_data)。
如果只是开启加密,工具会拒绝读取现有的明文状态(因为它可能是被篡改的明文)。所以要先设置 fallback,使它能读取一次明文,然后应用,让它用新方法重新写入。口令必须不少于 16 个字符。
迁移完成后,去掉明文回退并强制执行
在 encryption.tf 中删除 unencrypted 方法和两个 fallback,并在 state·plan 中设置 enforced = true。使用正确的口令时,计划必须是干净的。用错误的口令(TF_VAR_state_passphrase=wrong-passphrase-000000)运行 tofu plan,把输出保存到 /root/iac-secret/wrongkey.txt。评分器会在副本中确认用错误的密钥读取确实会失败。
如果保留 fallback,即使有人塞进一份明文状态也能被读取。enforced 会阻止以明文写入这件事本身。密钥一旦丢失,状态就永远无法读取,所以保管密钥就是保管状态。
加密的计划文件没有密钥就无法读取
用正确的口令用 tofu plan -out=/root/iac-secret/safe.tfplan 保存计划。这个文件中不得有明文密码,也不能是 zip。确认有密钥时 tofu show -json safe.tfplan 能够执行,没有密钥时会失败。
把它与第 3 步的 leak.tfplan 并排,用 head -c 2 比较开头两个字节。zip 以 PK 开头。计划加密由 plan 块决定,与 state 是分开的。
密钥放在状态之外,状态中只放引用
用 openssl rand -hex 16 在 /root/iac-secret/ref/secret/db.key 中生成密钥,并把权限设为 600(在工具之外生成的密钥)。在 /root/iac-secret/ref/main.tf 中不放 random_password,只放向 out/db.conf 写入一行 password_file=<그 경로>(占位符为该路径)的 local_file.dbconf,然后执行 init·apply。评分器会检查 ref 的状态中任何位置都没有 db.key 的值,只有路径。
只要工具生成或读取了值,这个值就会进入状态。让使用该值的一方(应用)直接从文件或密钥存储中读取,工具只告知位置,是最简单的规避办法。注意,即使用数据源读取密钥文件,它也会留在状态中。