@intentius/chant-lexicon-terraform 0.100.0 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/codegen/package.d.ts +1 -1
  2. package/dist/config.d.ts +2 -2
  3. package/dist/hcl/jsonencode.d.ts +106 -0
  4. package/dist/hcl/jsonencode.d.ts.map +1 -0
  5. package/dist/hcl/marker.d.ts.map +1 -1
  6. package/dist/integrity.json +11 -2
  7. package/dist/lint/audit-catalog.d.ts.map +1 -1
  8. package/dist/lint/audit-lineage.d.ts +1 -1
  9. package/dist/lint/audit-lineage.d.ts.map +1 -1
  10. package/dist/lint/post-synth/flat-attributes.d.ts +69 -0
  11. package/dist/lint/post-synth/flat-attributes.d.ts.map +1 -0
  12. package/dist/lint/post-synth/index.d.ts.map +1 -1
  13. package/dist/lint/post-synth/tf030.d.ts +51 -0
  14. package/dist/lint/post-synth/tf030.d.ts.map +1 -0
  15. package/dist/lint/post-synth/tf031.d.ts +55 -0
  16. package/dist/lint/post-synth/tf031.d.ts.map +1 -0
  17. package/dist/lint/post-synth/tf032.d.ts +71 -0
  18. package/dist/lint/post-synth/tf032.d.ts.map +1 -0
  19. package/dist/lint/post-synth/tf033.d.ts +50 -0
  20. package/dist/lint/post-synth/tf033.d.ts.map +1 -0
  21. package/dist/lint/post-synth/tf034.d.ts +25 -0
  22. package/dist/lint/post-synth/tf034.d.ts.map +1 -0
  23. package/dist/lint/post-synth/tf035.d.ts +33 -0
  24. package/dist/lint/post-synth/tf035.d.ts.map +1 -0
  25. package/dist/lint/post-synth/tf036.d.ts +33 -0
  26. package/dist/lint/post-synth/tf036.d.ts.map +1 -0
  27. package/dist/lint/post-synth/tf037.d.ts +31 -0
  28. package/dist/lint/post-synth/tf037.d.ts.map +1 -0
  29. package/dist/manifest.json +1 -1
  30. package/dist/okf/index.md +8 -0
  31. package/dist/okf/rules/TF030.md +11 -0
  32. package/dist/okf/rules/TF031.md +11 -0
  33. package/dist/okf/rules/TF032.md +11 -0
  34. package/dist/okf/rules/TF033.md +11 -0
  35. package/dist/okf/rules/TF034.md +11 -0
  36. package/dist/okf/rules/TF035.md +11 -0
  37. package/dist/okf/rules/TF036.md +11 -0
  38. package/dist/okf/rules/TF037.md +11 -0
  39. package/dist/op/activity-contracts.d.ts +18 -18
  40. package/dist/op/builders.d.ts.map +1 -1
  41. package/dist/rules/flat-attributes.ts +146 -0
  42. package/dist/rules/tf030.ts +290 -0
  43. package/dist/rules/tf031.ts +370 -0
  44. package/dist/rules/tf032.ts +226 -0
  45. package/dist/rules/tf033.ts +160 -0
  46. package/dist/rules/tf034.ts +63 -0
  47. package/dist/rules/tf035.ts +74 -0
  48. package/dist/rules/tf036.ts +119 -0
  49. package/dist/rules/tf037.ts +101 -0
  50. package/package.json +3 -3
  51. package/src/hcl/jsonencode.test.ts +226 -0
  52. package/src/hcl/jsonencode.ts +454 -0
  53. package/src/lint/audit-catalog.ts +148 -0
  54. package/src/lint/audit-lineage.ts +155 -1
  55. package/src/lint/post-synth/estate-2284.test.ts +68 -0
  56. package/src/lint/post-synth/fixtures/TF030/negative-rules.tf +45 -0
  57. package/src/lint/post-synth/fixtures/TF030/negative.tf +55 -0
  58. package/src/lint/post-synth/fixtures/TF030/not-determined-port.tf +28 -0
  59. package/src/lint/post-synth/fixtures/TF030/not-determined-standalone.tf +32 -0
  60. package/src/lint/post-synth/fixtures/TF030/not-determined-vpc-rule.tf +21 -0
  61. package/src/lint/post-synth/fixtures/TF030/not-determined.tf +39 -0
  62. package/src/lint/post-synth/fixtures/TF030/positive-ipv6.tf +44 -0
  63. package/src/lint/post-synth/fixtures/TF030/positive-mixed.tf +36 -0
  64. package/src/lint/post-synth/fixtures/TF030/positive-standalone.tf +25 -0
  65. package/src/lint/post-synth/fixtures/TF030/positive.tf +43 -0
  66. package/src/lint/post-synth/fixtures/TF031/negative.tf +89 -0
  67. package/src/lint/post-synth/fixtures/TF031/not-determined.tf +139 -0
  68. package/src/lint/post-synth/fixtures/TF031/positive-data-source.tf +57 -0
  69. package/src/lint/post-synth/fixtures/TF031/positive-heredoc.tf +149 -0
  70. package/src/lint/post-synth/fixtures/TF031/positive-jsonencode.tf +115 -0
  71. package/src/lint/post-synth/fixtures/TF032/negative-heredoc.tf +20 -0
  72. package/src/lint/post-synth/fixtures/TF032/negative-references.tf +214 -0
  73. package/src/lint/post-synth/fixtures/TF032/negative.tf +99 -0
  74. package/src/lint/post-synth/fixtures/TF032/positive-heredoc.tf +22 -0
  75. package/src/lint/post-synth/fixtures/TF032/positive-sidecar.tf +197 -0
  76. package/src/lint/post-synth/fixtures/TF032/positive.tf +54 -0
  77. package/src/lint/post-synth/fixtures/TF033/negative.tf +39 -0
  78. package/src/lint/post-synth/fixtures/TF033/not-determined-aurora.tf +25 -0
  79. package/src/lint/post-synth/fixtures/TF033/positive-absent.tf +93 -0
  80. package/src/lint/post-synth/fixtures/TF033/positive.tf +50 -0
  81. package/src/lint/post-synth/fixtures/TF034/negative.tf +24 -0
  82. package/src/lint/post-synth/fixtures/TF034/positive-absent.tf +36 -0
  83. package/src/lint/post-synth/fixtures/TF034/positive.tf +14 -0
  84. package/src/lint/post-synth/fixtures/TF035/negative-absent.tf +43 -0
  85. package/src/lint/post-synth/fixtures/TF035/negative.tf +54 -0
  86. package/src/lint/post-synth/fixtures/TF035/positive.tf +37 -0
  87. package/src/lint/post-synth/fixtures/TF036/negative.tf +61 -0
  88. package/src/lint/post-synth/fixtures/TF036/not-determined.tf +28 -0
  89. package/src/lint/post-synth/fixtures/TF036/positive.tf +38 -0
  90. package/src/lint/post-synth/fixtures/TF037/negative-exclusion.tf +63 -0
  91. package/src/lint/post-synth/fixtures/TF037/negative.tf +27 -0
  92. package/src/lint/post-synth/fixtures/TF037/not-determined.tf +27 -0
  93. package/src/lint/post-synth/fixtures/TF037/positive-absent.tf +15 -0
  94. package/src/lint/post-synth/fixtures/TF037/positive.tf +26 -0
  95. package/src/lint/post-synth/fixtures/estate-2284/clean/main.tf +132 -0
  96. package/src/lint/post-synth/fixtures/estate-2284/violating/main.tf +112 -0
  97. package/src/lint/post-synth/flat-attributes.ts +146 -0
  98. package/src/lint/post-synth/index.ts +16 -0
  99. package/src/lint/post-synth/tf030.test.ts +219 -0
  100. package/src/lint/post-synth/tf030.ts +290 -0
  101. package/src/lint/post-synth/tf031.test.ts +229 -0
  102. package/src/lint/post-synth/tf031.ts +370 -0
  103. package/src/lint/post-synth/tf032.test.ts +127 -0
  104. package/src/lint/post-synth/tf032.ts +226 -0
  105. package/src/lint/post-synth/tf033.test.ts +126 -0
  106. package/src/lint/post-synth/tf033.ts +160 -0
  107. package/src/lint/post-synth/tf034.test.ts +71 -0
  108. package/src/lint/post-synth/tf034.ts +63 -0
  109. package/src/lint/post-synth/tf035.test.ts +68 -0
  110. package/src/lint/post-synth/tf035.ts +74 -0
  111. package/src/lint/post-synth/tf036.test.ts +87 -0
  112. package/src/lint/post-synth/tf036.ts +119 -0
  113. package/src/lint/post-synth/tf037.test.ts +88 -0
  114. package/src/lint/post-synth/tf037.ts +101 -0
  115. package/src/op/activities/terraform.test.ts +2 -2
@@ -0,0 +1,226 @@
1
+ /**
2
+ * TF032: an ECS container definition passes a credential as a plaintext
3
+ * environment value.
4
+ *
5
+ * The Terraform counterpart of the aws lexicon's WAW046. A value under a
6
+ * container's `environment` is stored verbatim in the task definition: it is
7
+ * readable by anyone with `ecs:DescribeTaskDefinition`, it shows in the
8
+ * console, and it is in Terraform state and plan output. ECS has a field for
9
+ * exactly this, `secrets`, whose `valueFrom` is a Secrets Manager or SSM
10
+ * Parameter Store ARN that the agent resolves at task start. A credential
11
+ * belongs there.
12
+ *
13
+ * Why this is a native rule and not WAW046 through a bridge
14
+ * (docs/design/waw-hcl-fidelity-probe.md): `container_definitions` is a JSON
15
+ * string. In HCL it is almost always `jsonencode([...])`, which hcl2json hands
16
+ * back as the call's unevaluated source text, so the structure WAW046 reads
17
+ * does not exist in the parse until something reads that text.
18
+ * `../../hcl/jsonencode.ts` does, for literal structure only, and never
19
+ * evaluates anything.
20
+ *
21
+ * The three answers, per task definition:
22
+ *
23
+ * - Readable. `container_definitions` is `jsonencode([...])` whose list and
24
+ * containers are literal, or a plain JSON string (a heredoc with no
25
+ * interpolation is one). A reference or function call in a leaf
26
+ * (`image = var.image`, `value = var.db_password`) does not stop the read:
27
+ * that leaf is unknown and the literal fields beside it are still read. An
28
+ * environment entry whose value is a reference is skipped, since a
29
+ * reference is not a committed literal (TF007 and TF022 judge where the
30
+ * referenced value comes from).
31
+ * - Violation. A readable environment entry whose `value` is a literal string
32
+ * and is secret-shaped by `../secret-shape.ts`: a credential-looking name
33
+ * with a literal of at least 8 characters that is not a placeholder, or a
34
+ * value that is a credential on its own (a vendor key shape, a JWT, a PEM
35
+ * key, a high-entropy token) whatever it is called.
36
+ * - Not determined. `container_definitions` is a reference
37
+ * (`local.defs`, `data.template_file.x.rendered`), `file()`,
38
+ * `templatefile()`, a heredoc or string with interpolation, a jsonencode
39
+ * argument that is not a literal list (`jsonencode(concat(...))`,
40
+ * `jsonencode(local.containers)`), or a container whose `environment` is an
41
+ * expression (`environment = local.env`). One `info` diagnostic starting
42
+ * `Not determined:` per task definition or container, never a warning.
43
+ *
44
+ * Absence: a container with no `environment` is not insecure, and a task
45
+ * definition with no `container_definitions` is not reported (the attribute
46
+ * is required, so that is a plan error, not this rule's finding). A name in
47
+ * `secrets` is never reported: `secrets` is not read at all.
48
+ *
49
+ * Findings never carry the value, only its length.
50
+ */
51
+
52
+ import type {
53
+ PostSynthCheck,
54
+ PostSynthContext,
55
+ PostSynthDiagnostic,
56
+ } from "@intentius/chant/lint/post-synth";
57
+ import { RESOURCE_TYPE } from "../../hcl/parse";
58
+ import { attr } from "../../hcl/value";
59
+ import { isUnknown, readJsonencode } from "../../hcl/jsonencode";
60
+ import { secretShapedAssignment } from "../secret-shape";
61
+ import { blocksOfType, type TerraformBlock } from "./blocks";
62
+
63
+ const TASK_DEFINITION = "aws_ecs_task_definition";
64
+
65
+ /** The container definitions of one task definition, or why they cannot be read. */
66
+ export type ContainerDefinitionsRead =
67
+ | { kind: "readable"; containers: unknown }
68
+ | { kind: "not-determined"; reason: string }
69
+ | { kind: "absent" };
70
+
71
+ /**
72
+ * Read `container_definitions` as structure without evaluating anything.
73
+ * `jsonencode(...)` goes through the shared reader with unknown leaves; a
74
+ * plain string with no interpolation is JSON (a heredoc arrives as one);
75
+ * everything else is named in the not-determined reason.
76
+ */
77
+ export function readContainerDefinitions(body: Record<string, unknown>): ContainerDefinitionsRead {
78
+ const raw = body.container_definitions;
79
+ if (raw === undefined || raw === null) return { kind: "absent" };
80
+
81
+ const encoded = readJsonencode(raw, { unknownLeaves: true });
82
+ if (encoded.kind === "literal") return { kind: "readable", containers: encoded.value };
83
+ if (encoded.kind === "not-determined") {
84
+ return { kind: "not-determined", reason: `its jsonencode argument holds ${encoded.reason}` };
85
+ }
86
+
87
+ if (typeof raw !== "string") return { kind: "not-determined", reason: "it is not a string" };
88
+ // attr() alone is not enough to call a string literal: its interpolation
89
+ // pattern tolerates one level of nested braces, so a deeper expression can
90
+ // read as `literal`. Any `${` at all means the parse did not hand us text.
91
+ if (!raw.includes("${")) {
92
+ try {
93
+ return { kind: "readable", containers: JSON.parse(raw) };
94
+ } catch {
95
+ return { kind: "not-determined", reason: "it is a string that is not valid JSON" };
96
+ }
97
+ }
98
+ return { kind: "not-determined", reason: describeExpression(raw) };
99
+ }
100
+
101
+ /** Name the expression an unreadable `container_definitions` holds, for the message. */
102
+ function describeExpression(raw: string): string {
103
+ const a = attr({ v: raw }, "v");
104
+ if (a.kind === "reference" && a.refs?.[0] !== undefined) {
105
+ const expr = a.refs[0];
106
+ const call = /^([a-z_][a-z0-9_]*)\s*\(/i.exec(expr);
107
+ if (call) return `it is a call to ${call[1]}(), which this rule does not evaluate`;
108
+ return `it is the reference \`${expr}\`, whose value is not in this module's source`;
109
+ }
110
+ return "it is a string or heredoc with interpolation, which this rule does not evaluate";
111
+ }
112
+
113
+ /** A container's display name: its literal `name`, else its position. */
114
+ function containerName(container: Record<string, unknown>, index: number): string {
115
+ return typeof container.name === "string" ? container.name : `#${index}`;
116
+ }
117
+
118
+ function notDetermined(block: TerraformBlock, what: string, reason: string): PostSynthDiagnostic {
119
+ return {
120
+ checkId: "TF032",
121
+ severity: "info",
122
+ message:
123
+ `Not determined: "${block.address}" ${what}: ${reason}. TF032 reads only literal structure, ` +
124
+ "so a plaintext credential in it can be neither found nor ruled out.",
125
+ entity: block.key,
126
+ lexicon: "terraform",
127
+ };
128
+ }
129
+
130
+ export function checkEcsPlaintextCredentials(ctx: PostSynthContext): PostSynthDiagnostic[] {
131
+ const diagnostics: PostSynthDiagnostic[] = [];
132
+
133
+ for (const block of blocksOfType(ctx, RESOURCE_TYPE)) {
134
+ if (!block.address.startsWith(`${TASK_DEFINITION}.`)) continue;
135
+
136
+ const read = readContainerDefinitions(block.body);
137
+ if (read.kind === "absent") continue;
138
+ if (read.kind === "not-determined") {
139
+ diagnostics.push(notDetermined(block, "`container_definitions`", read.reason));
140
+ continue;
141
+ }
142
+ // A literal that is not a list is not a valid container_definitions; the
143
+ // provider rejects it at plan, and there is no environment to read.
144
+ if (!Array.isArray(read.containers)) continue;
145
+
146
+ read.containers.forEach((container, index) => {
147
+ if (isUnknown(container)) {
148
+ diagnostics.push(
149
+ notDetermined(block, `container #${index} of \`container_definitions\``, `it is ${container.reason}`),
150
+ );
151
+ return;
152
+ }
153
+ if (typeof container !== "object" || container === null || Array.isArray(container)) return;
154
+ const c = container as Record<string, unknown>;
155
+ const name = containerName(c, index);
156
+
157
+ const env = c.environment;
158
+ if (env === undefined || env === null) return;
159
+ if (isUnknown(env)) {
160
+ diagnostics.push(notDetermined(block, `container "${name}" \`environment\``, `it is ${env.reason}`));
161
+ return;
162
+ }
163
+ if (!Array.isArray(env)) return;
164
+
165
+ const unreadable: string[] = [];
166
+ for (const entry of env) {
167
+ if (isUnknown(entry)) {
168
+ unreadable.push(entry.reason);
169
+ continue;
170
+ }
171
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
172
+ const e = entry as Record<string, unknown>;
173
+ if (isUnknown(e.name)) {
174
+ // A computed variable name could be anything, so the entry is
175
+ // unreadable only when it also carries a literal value to judge.
176
+ if (typeof e.value === "string") unreadable.push(e.name.reason);
177
+ continue;
178
+ }
179
+ if (typeof e.name !== "string") continue;
180
+ // A reference value is the remediation's neighbour, not a literal:
181
+ // skipped. Numbers and booleans are not credentials.
182
+ if (typeof e.value !== "string") continue;
183
+
184
+ const shape = secretShapedAssignment(e.name, e.value);
185
+ if (!shape) continue;
186
+ const why =
187
+ shape.reason === "name"
188
+ ? "its name reads like a credential"
189
+ : "the value has the shape of a credential";
190
+ diagnostics.push({
191
+ checkId: "TF032",
192
+ severity: "error",
193
+ message:
194
+ `"${block.address}" container "${name}" passes "${e.name}" as a plaintext environment value ` +
195
+ `(${shape.length} characters, redacted here; ${why}). It is stored in the task definition, ` +
196
+ "visible to anyone who can describe it, and in state and plan output. Move it to the " +
197
+ "container's `secrets` with a `valueFrom` naming a Secrets Manager secret or SSM parameter, " +
198
+ "and rotate the committed value.",
199
+ entity: block.key,
200
+ lexicon: "terraform",
201
+ });
202
+ }
203
+
204
+ if (unreadable.length > 0) {
205
+ diagnostics.push(
206
+ notDetermined(
207
+ block,
208
+ `container "${name}" \`environment\``,
209
+ `${unreadable.length} entr${unreadable.length === 1 ? "y is" : "ies are"} not literal (${unreadable[0]})`,
210
+ ),
211
+ );
212
+ }
213
+ });
214
+ }
215
+
216
+ return diagnostics;
217
+ }
218
+
219
+ export const tf032: PostSynthCheck = {
220
+ id: "TF032",
221
+ description: "ECS container definition passes a credential as a plaintext environment value",
222
+
223
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
224
+ return checkEcsPlaintextCredentials(ctx);
225
+ },
226
+ };
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
3
+ import { blocksToEntities } from "../../hcl/parse";
4
+ import { loadFixture } from "./fixtures/load";
5
+ import { tf033 } from "./tf033";
6
+
7
+ async function inline(source: string): Promise<PostSynthContext> {
8
+ const entities = await blocksToEntities([{ name: "main.tf", source }], "app");
9
+ return { outputs: new Map(), entities } as unknown as PostSynthContext;
10
+ }
11
+
12
+ const verdicts = (d: PostSynthDiagnostic[]) => d.map((x) => `${x.severity} ${x.entity}`).sort();
13
+
14
+ describe("TF033 over vendored real-world fixtures", () => {
15
+ test("storage_encrypted = true is silent (tmknom/example-pragmatic-terraform)", async () => {
16
+ expect(tf033.check(await loadFixture("TF033", "negative"))).toEqual([]);
17
+ });
18
+
19
+ test("storage_encrypted = false is an error (bridgecrewio/terragoat)", async () => {
20
+ const diags = tf033.check(await loadFixture("TF033", "positive"));
21
+ expect(verdicts(diags)).toEqual(["error TF033/aws_db_instance.default"]);
22
+ expect(diags[0]).toMatchObject({ checkId: "TF033", lexicon: "terraform" });
23
+ expect(diags[0].message).toContain("sets `storage_encrypted = false`");
24
+ });
25
+
26
+ test("an instance that does not set storage_encrypted is an error; snapshot_identifier = \"\" is no source (zoitech)", async () => {
27
+ const diags = tf033.check(await loadFixture("TF033", "positive-absent"));
28
+ expect(verdicts(diags)).toEqual(["error TF033/aws_db_instance.postgres"]);
29
+ expect(diags[0].message).toContain("the provider default is `false`");
30
+ });
31
+
32
+ test("an Aurora cluster without storage_encrypted is not determined (bridgecrewio/terragoat)", async () => {
33
+ const diags = tf033.check(await loadFixture("TF033", "not-determined-aurora"));
34
+ expect(verdicts(diags)).toEqual(["info TF033/aws_rds_cluster.app1-rds-cluster"]);
35
+ expect(diags[0].message).toMatch(/^Not determined: /);
36
+ expect(diags[0].message).toContain("2026-02-16");
37
+ });
38
+ });
39
+
40
+ describe("TF033 cases the fixtures do not isolate", () => {
41
+ test("an instance created from a source is not determined unless storage_encrypted = true", async () => {
42
+ const diags = tf033.check(
43
+ await inline(`
44
+ resource "aws_db_instance" "replica" {
45
+ replicate_source_db = aws_db_instance.primary.identifier
46
+ }
47
+ resource "aws_db_instance" "restored" {
48
+ snapshot_identifier = "rds:prod-2026-01-01"
49
+ storage_encrypted = false
50
+ }
51
+ resource "aws_db_instance" "pitr" {
52
+ restore_to_point_in_time {
53
+ use_latest_restorable_time = true
54
+ }
55
+ }
56
+ resource "aws_db_instance" "explicit" {
57
+ snapshot_identifier = "rds:prod-2026-01-01"
58
+ storage_encrypted = true
59
+ }`),
60
+ );
61
+ expect(verdicts(diags)).toEqual([
62
+ "info app/aws_db_instance.pitr",
63
+ "info app/aws_db_instance.replica",
64
+ "info app/aws_db_instance.restored",
65
+ ]);
66
+ expect(diags.find((d) => d.entity === "app/aws_db_instance.replica")!.message).toContain("`replicate_source_db`");
67
+ });
68
+
69
+ test("a storage_encrypted expression is not determined; the string \"true\" is true", async () => {
70
+ const diags = tf033.check(
71
+ await inline(`
72
+ resource "aws_db_instance" "v" {
73
+ storage_encrypted = var.encrypted
74
+ }
75
+ resource "aws_db_instance" "s" {
76
+ storage_encrypted = "true"
77
+ }`),
78
+ );
79
+ expect(verdicts(diags)).toEqual(["info app/aws_db_instance.v"]);
80
+ expect(diags[0].message).toContain("`storage_encrypted` is an expression (var.encrypted)");
81
+ });
82
+
83
+ test("a Multi-AZ DB cluster defaults to unencrypted; Aurora false and an engine expression are not determined", async () => {
84
+ const diags = tf033.check(
85
+ await inline(`
86
+ resource "aws_rds_cluster" "multi_az" {
87
+ engine = "postgres"
88
+ db_cluster_instance_class = "db.r6gd.large"
89
+ }
90
+ resource "aws_rds_cluster" "multi_az_off" {
91
+ engine = "mysql"
92
+ storage_encrypted = false
93
+ }
94
+ resource "aws_rds_cluster" "aurora_off" {
95
+ engine = "aurora-postgresql"
96
+ storage_encrypted = false
97
+ }
98
+ resource "aws_rds_cluster" "engine_var" {
99
+ engine = var.engine
100
+ }
101
+ resource "aws_rds_cluster" "restored" {
102
+ engine = "postgres"
103
+ snapshot_identifier = "arn:aws:rds:eu-west-1:111122223333:cluster-snapshot:x"
104
+ }
105
+ resource "aws_rds_cluster" "serverless_v1" {
106
+ engine = "aurora-mysql"
107
+ engine_mode = "serverless"
108
+ }
109
+ resource "aws_rds_cluster" "on" {
110
+ engine = "postgres"
111
+ storage_encrypted = true
112
+ }`),
113
+ );
114
+ expect(verdicts(diags)).toEqual([
115
+ "error app/aws_rds_cluster.multi_az",
116
+ "error app/aws_rds_cluster.multi_az_off",
117
+ "info app/aws_rds_cluster.aurora_off",
118
+ "info app/aws_rds_cluster.engine_var",
119
+ "info app/aws_rds_cluster.restored",
120
+ ]);
121
+ });
122
+
123
+ test("aws_rds_cluster_instance is never read", async () => {
124
+ expect(tf033.check(await inline(`resource "aws_rds_cluster_instance" "i" { cluster_identifier = "c" }`))).toEqual([]);
125
+ });
126
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * TF033: an RDS DB instance or cluster does not encrypt its storage
3
+ * (chant #2288, epic #2284).
4
+ *
5
+ * The Terraform counterpart of the aws lexicon's WAW021, written natively.
6
+ * WAW021 reads a template chant synthesized, where `StorageEncrypted` absent
7
+ * means false. Reading somebody else's HCL, absence means different things
8
+ * for the two resources and for how the database is created, so each case is
9
+ * decided here against the provider schema and the AWS default.
10
+ *
11
+ * `aws_db_instance` (provider `internal/service/rds/instance.go`, v6.67.0):
12
+ * `storage_encrypted` is optional, not computed, and the docs say "The
13
+ * default is `false` if not specified". The plain create path and `s3_import`
14
+ * send `StorageEncrypted: d.Get("storage_encrypted")`, so absent means false
15
+ * and is reported. The replica (`replicate_source_db`), snapshot
16
+ * (`snapshot_identifier`) and point-in-time (`restore_to_point_in_time`)
17
+ * paths never send it: the new instance takes its encryption from the source
18
+ * (and a cross-region replica from `kms_key_id`). For those the rule cannot
19
+ * know, and says so, unless `storage_encrypted = true` is written down.
20
+ *
21
+ * `aws_rds_cluster` (`cluster.go`): `storage_encrypted` is optional and
22
+ * computed, sent only when set, and "Terraform will only perform drift
23
+ * detection if a configuration value is provided". Two engine families:
24
+ *
25
+ * - Aurora (`engine` `aurora-mysql`/`aurora-postgresql`, or absent, which
26
+ * provider v5 defaulted to `aurora`). Since 2026-02-16 AWS encrypts every
27
+ * new Aurora cluster at rest with an AWS-owned key whatever
28
+ * `StorageEncrypted` says, and reports `StorageEncrypted: false` with
29
+ * `StorageEncryptionType: sse-rds` for it. A cluster created before that
30
+ * date with storage encryption off is unencrypted, and the configuration
31
+ * does not say when its cluster was created. So an Aurora cluster with
32
+ * `storage_encrypted` absent or `false` is not determined, not a finding.
33
+ * `engine_mode = "serverless"` (Serverless v1) was always encrypted and
34
+ * is not reported at all.
35
+ * - Multi-AZ DB clusters (`engine` `mysql`/`postgres`): the default is
36
+ * `false` and nothing changed it, so absent or `false` is a finding,
37
+ * unless the cluster is restored or replicated from a source
38
+ * (`snapshot_identifier`, `restore_to_point_in_time`,
39
+ * `replication_source_identifier`, `global_cluster_identifier`), which
40
+ * again decides the encryption.
41
+ *
42
+ * Not checked: `aws_rds_cluster_instance` (its `storage_encrypted` is
43
+ * computed-only and never appears in a body), `aws_rds_global_cluster`, and
44
+ * whether the key is customer managed.
45
+ *
46
+ * Scope: root and child modules alike (#2112).
47
+ */
48
+
49
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
50
+ import type { BlockBody } from "../../hcl/parse";
51
+ import type { TerraformBlock } from "./blocks";
52
+ import { finding, isGiven, notDetermined, readBool, readString, resourcesOfType, type Read } from "./flat-attributes";
53
+
54
+ const ID = "TF033";
55
+
56
+ const NOT_DETERMINED_TAIL =
57
+ "TF033 does not evaluate expressions or read the source database, so it cannot tell whether this storage is encrypted.";
58
+
59
+ /** The first source argument a body gives, if any. */
60
+ function sourceOf(body: BlockBody, names: readonly string[]): string | undefined {
61
+ return names.find((n) => isGiven(body, n));
62
+ }
63
+
64
+ const INSTANCE_SOURCES = ["replicate_source_db", "snapshot_identifier", "restore_to_point_in_time"] as const;
65
+ const CLUSTER_SOURCES = [
66
+ "snapshot_identifier",
67
+ "restore_to_point_in_time",
68
+ "replication_source_identifier",
69
+ "global_cluster_identifier",
70
+ ] as const;
71
+
72
+ function unencrypted(block: TerraformBlock, encrypted: Read<boolean>, what: string): PostSynthDiagnostic {
73
+ const how =
74
+ encrypted.kind === "absent"
75
+ ? "does not set `storage_encrypted`, and the provider default is `false`"
76
+ : "sets `storage_encrypted = false`";
77
+ return finding(
78
+ ID,
79
+ "error",
80
+ block,
81
+ `${how}, so the ${what} stores its data unencrypted. Set \`storage_encrypted = true\` (and \`kms_key_id\` for a ` +
82
+ "customer managed key). Encryption cannot be turned on in place: an existing database is migrated by restoring " +
83
+ "an encrypted copy of a snapshot.",
84
+ );
85
+ }
86
+
87
+ function checkInstance(block: TerraformBlock): PostSynthDiagnostic | undefined {
88
+ const encrypted = readBool(block.body, "storage_encrypted");
89
+ if (encrypted.kind === "known" && encrypted.value) return undefined;
90
+ if (encrypted.kind === "unknown") return notDetermined(ID, block, encrypted.reason, NOT_DETERMINED_TAIL);
91
+ const source = sourceOf(block.body, INSTANCE_SOURCES);
92
+ if (source) {
93
+ return notDetermined(
94
+ ID,
95
+ block,
96
+ `is created from a source (\`${source}\`), and the provider does not send \`storage_encrypted\` on that path: ` +
97
+ "the instance takes its encryption from the source",
98
+ NOT_DETERMINED_TAIL,
99
+ );
100
+ }
101
+ return unencrypted(block, encrypted, "DB instance");
102
+ }
103
+
104
+ function checkCluster(block: TerraformBlock): PostSynthDiagnostic | undefined {
105
+ const encrypted = readBool(block.body, "storage_encrypted");
106
+ if (encrypted.kind === "known" && encrypted.value) return undefined;
107
+ if (encrypted.kind === "unknown") return notDetermined(ID, block, encrypted.reason, NOT_DETERMINED_TAIL);
108
+
109
+ const engine = readString(block.body, "engine");
110
+ if (engine.kind === "unknown") {
111
+ return notDetermined(
112
+ ID,
113
+ block,
114
+ `${engine.reason}, and whether an unset \`storage_encrypted\` means unencrypted depends on the engine`,
115
+ NOT_DETERMINED_TAIL,
116
+ );
117
+ }
118
+ const aurora = engine.kind === "absent" || engine.value.startsWith("aurora");
119
+ if (aurora) {
120
+ const mode = readString(block.body, "engine_mode");
121
+ if (mode.kind === "known" && mode.value === "serverless") return undefined;
122
+ const set = encrypted.kind === "absent" ? "does not set `storage_encrypted`" : "sets `storage_encrypted = false`";
123
+ return notDetermined(
124
+ ID,
125
+ block,
126
+ `is an Aurora cluster that ${set}. AWS encrypts every Aurora cluster created on or after 2026-02-16 with an ` +
127
+ "AWS-owned key whatever this says; one created earlier with storage encryption off is unencrypted, and the " +
128
+ "configuration does not say when the cluster was created",
129
+ "Set `storage_encrypted = true` to make the answer explicit.",
130
+ );
131
+ }
132
+ const source = sourceOf(block.body, CLUSTER_SOURCES);
133
+ if (source) {
134
+ return notDetermined(
135
+ ID,
136
+ block,
137
+ `is created from a source (\`${source}\`), which decides its encryption`,
138
+ NOT_DETERMINED_TAIL,
139
+ );
140
+ }
141
+ return unencrypted(block, encrypted, "Multi-AZ DB cluster");
142
+ }
143
+
144
+ export const tf033: PostSynthCheck = {
145
+ id: "TF033",
146
+ description: "RDS DB instance or cluster storage is not encrypted",
147
+
148
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
149
+ const out: PostSynthDiagnostic[] = [];
150
+ for (const block of resourcesOfType(ctx, "aws_db_instance")) {
151
+ const d = checkInstance(block);
152
+ if (d) out.push(d);
153
+ }
154
+ for (const block of resourcesOfType(ctx, "aws_rds_cluster")) {
155
+ const d = checkCluster(block);
156
+ if (d) out.push(d);
157
+ }
158
+ return out;
159
+ },
160
+ };
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
3
+ import { blocksToEntities } from "../../hcl/parse";
4
+ import { loadFixture } from "./fixtures/load";
5
+ import { tf034 } from "./tf034";
6
+
7
+ async function inline(source: string): Promise<PostSynthContext> {
8
+ const entities = await blocksToEntities([{ name: "main.tf", source }], "app");
9
+ return { outputs: new Map(), entities } as unknown as PostSynthContext;
10
+ }
11
+
12
+ const verdicts = (d: PostSynthDiagnostic[]) => d.map((x) => `${x.severity} ${x.entity}`).sort();
13
+
14
+ describe("TF034 over vendored real-world fixtures", () => {
15
+ test("a key from a managed KMS alias is silent (covidgreen/covid-green-infra)", async () => {
16
+ expect(tf034.check(await loadFixture("TF034", "negative"))).toEqual([]);
17
+ });
18
+
19
+ test('kms_master_key_id = "" is a warning (KICS test corpus)', async () => {
20
+ const diags = tf034.check(await loadFixture("TF034", "positive"));
21
+ expect(verdicts(diags)).toEqual(["warning TF034/aws_sns_topic.user_updates"]);
22
+ expect(diags[0]).toMatchObject({ checkId: "TF034", lexicon: "terraform" });
23
+ expect(diags[0].message).toContain('sets `kms_master_key_id = ""`');
24
+ });
25
+
26
+ test("a topic with no key is a warning: absence is insecure for SNS (radian-software/riju)", async () => {
27
+ const diags = tf034.check(await loadFixture("TF034", "positive-absent"));
28
+ expect(verdicts(diags)).toEqual(["warning TF034/aws_sns_topic.riju"]);
29
+ expect(diags[0].message).toContain("does not set `kms_master_key_id`");
30
+ });
31
+ });
32
+
33
+ describe("TF034 cases the fixtures do not isolate", () => {
34
+ test("the AWS managed alias, a template with literal text and a data source attribute are keys", async () => {
35
+ const diags = tf034.check(
36
+ await inline(`
37
+ resource "aws_sns_topic" "managed" {
38
+ kms_master_key_id = "alias/aws/sns"
39
+ }
40
+ resource "aws_sns_topic" "templated" {
41
+ kms_master_key_id = "alias/\${var.env}-sns"
42
+ }
43
+ resource "aws_sns_topic" "data" {
44
+ kms_master_key_id = data.aws_kms_key.sns.arn
45
+ }`),
46
+ );
47
+ expect(diags).toEqual([]);
48
+ });
49
+
50
+ test("a variable, a conditional or a bare interpolation is not determined", async () => {
51
+ const diags = tf034.check(
52
+ await inline(`
53
+ resource "aws_sns_topic" "v" {
54
+ kms_master_key_id = var.kms_key_id
55
+ }
56
+ resource "aws_sns_topic" "c" {
57
+ kms_master_key_id = var.encrypt ? aws_kms_key.k.arn : null
58
+ }
59
+ resource "aws_sns_topic" "t" {
60
+ kms_master_key_id = "\${var.a}\${var.b}"
61
+ }`),
62
+ );
63
+ expect(verdicts(diags)).toEqual(["info app/aws_sns_topic.c", "info app/aws_sns_topic.t", "info app/aws_sns_topic.v"]);
64
+ for (const d of diags) expect(d.message).toMatch(/^Not determined: /);
65
+ });
66
+
67
+ test("kms_master_key_id = null is the same as absent", async () => {
68
+ const diags = tf034.check(await inline(`resource "aws_sns_topic" "n" {\n kms_master_key_id = null\n}`));
69
+ expect(verdicts(diags)).toEqual(["warning app/aws_sns_topic.n"]);
70
+ });
71
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * TF034: an SNS topic has no server-side encryption (chant #2288, epic #2284).
3
+ *
4
+ * The Terraform counterpart of the aws lexicon's WAW025, written natively.
5
+ *
6
+ * Absence is insecure for this resource, and the rule reports it.
7
+ * `aws_sns_topic.kms_master_key_id` (provider `internal/service/sns/topic.go`,
8
+ * v6.67.0) is optional with no default, and SNS has no service-owned
9
+ * encryption that applies when no key is named: server-side encryption
10
+ * (https://docs.aws.amazon.com/sns/latest/dg/sns-server-side-encryption.html)
11
+ * is on only when the topic names a KMS key, either the AWS managed
12
+ * `alias/aws/sns` or a customer managed key. Unlike SQS (TF035), no AWS
13
+ * default turns it on for new topics. A literal `kms_master_key_id = ""`
14
+ * names no key and is the same finding.
15
+ *
16
+ * Present and secure: a non-empty literal, an attribute of a managed resource
17
+ * or data source (`aws_kms_key.sns.arn`, `aws_kms_alias.sns.arn`), or a
18
+ * template with literal text. Not determined: a variable, local, module
19
+ * output, conditional or function call, any of which can be `null` or `""`.
20
+ *
21
+ * Scope: root and child modules alike (#2112).
22
+ */
23
+
24
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
25
+ import { finding, notDetermined, readKeySet, resourcesOfType } from "./flat-attributes";
26
+
27
+ const ID = "TF034";
28
+
29
+ export const tf034: PostSynthCheck = {
30
+ id: "TF034",
31
+ description: "SNS topic has no server-side encryption",
32
+
33
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
34
+ const out: PostSynthDiagnostic[] = [];
35
+ for (const block of resourcesOfType(ctx, "aws_sns_topic")) {
36
+ const key = readKeySet(block.body, "kms_master_key_id");
37
+ if (key.kind === "known" && key.value !== "") continue;
38
+ if (key.kind === "unknown") {
39
+ out.push(
40
+ notDetermined(
41
+ ID,
42
+ block,
43
+ key.reason,
44
+ "TF034 does not evaluate expressions, so it cannot tell whether this names a KMS key or is null.",
45
+ ),
46
+ );
47
+ continue;
48
+ }
49
+ const how = key.kind === "absent" ? "does not set `kms_master_key_id`" : 'sets `kms_master_key_id = ""`';
50
+ out.push(
51
+ finding(
52
+ ID,
53
+ "warning",
54
+ block,
55
+ `${how}, so SNS stores its messages without server-side encryption; no AWS default encrypts a topic that ` +
56
+ 'names no key. Set `kms_master_key_id` to a customer managed key, or to `"alias/aws/sns"` for the AWS ' +
57
+ "managed one.",
58
+ ),
59
+ );
60
+ }
61
+ return out;
62
+ },
63
+ };