@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,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,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
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * TF035: an SQS queue turns its server-side encryption off (chant #2288,
3
+ * epic #2284).
4
+ *
5
+ * The Terraform counterpart of the aws lexicon's WAW026, and the case that
6
+ * shows why the five flat rules could not be bridged. WAW026 reports a queue
7
+ * with neither `SqsManagedSseEnabled: true` nor `KmsMasterKeyId`. Bridged onto
8
+ * HCL, that reports every correctly encrypted queue in an estate
9
+ * (docs/design/waw-hcl-fidelity-probe.md).
10
+ *
11
+ * Absence is secure for this resource, and the rule does not report it.
12
+ * `sqs_managed_sse_enabled` is optional and computed (provider
13
+ * `internal/service/sqs/queue.go`, v6.67.0), and the provider docs say
14
+ * Terraform "will only perform drift detection of its value when present in
15
+ * a configuration". Since October 2022 SQS encrypts every new queue with
16
+ * SSE-SQS unless the request turns it off
17
+ * (https://aws.amazon.com/blogs/compute/announcing-server-side-encryption-with-amazon-simple-queue-service-managed-encryption-keys-sse-sqs-by-default/),
18
+ * and the provider creates queues over HTTPS, so a queue that sets neither
19
+ * attribute is encrypted. A queue created before that date and never
20
+ * changed can still be unencrypted; the configuration cannot show that, and
21
+ * TF035 does not guess it.
22
+ *
23
+ * The finding: `sqs_managed_sse_enabled = false` with no KMS key. Present and
24
+ * secure: `sqs_managed_sse_enabled = true`, or a `kms_master_key_id` that is
25
+ * a non-empty literal, a managed key's attribute, or a template with literal
26
+ * text. Not determined: `sqs_managed_sse_enabled` from an expression with no
27
+ * key, or a key from a variable beside `sqs_managed_sse_enabled = false`.
28
+ *
29
+ * Scope: root and child modules alike (#2112).
30
+ */
31
+
32
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
33
+ import { finding, notDetermined, readBool, readKeySet, resourcesOfType } from "./flat-attributes";
34
+
35
+ const ID = "TF035";
36
+
37
+ const TAIL = "TF035 does not evaluate expressions, so it cannot tell whether this queue encrypts its messages.";
38
+
39
+ export const tf035: PostSynthCheck = {
40
+ id: "TF035",
41
+ description: "SQS queue turns server-side encryption off",
42
+
43
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
44
+ const out: PostSynthDiagnostic[] = [];
45
+ for (const block of resourcesOfType(ctx, "aws_sqs_queue")) {
46
+ const key = readKeySet(block.body, "kms_master_key_id");
47
+ const sse = readBool(block.body, "sqs_managed_sse_enabled");
48
+ if (key.kind === "known" && key.value !== "") continue;
49
+ if (sse.kind === "known" && sse.value) continue;
50
+ // Neither attribute turns encryption off: AWS's SSE-SQS default applies.
51
+ if (sse.kind === "absent") continue;
52
+
53
+ if (sse.kind === "unknown") {
54
+ out.push(notDetermined(ID, block, `${sse.reason} and names no KMS key`, TAIL));
55
+ continue;
56
+ }
57
+ if (key.kind === "unknown") {
58
+ out.push(notDetermined(ID, block, `sets \`sqs_managed_sse_enabled = false\` and ${key.reason}`, TAIL));
59
+ continue;
60
+ }
61
+ out.push(
62
+ finding(
63
+ ID,
64
+ "warning",
65
+ block,
66
+ "sets `sqs_managed_sse_enabled = false` and names no KMS key, which turns off the SSE-SQS encryption AWS " +
67
+ "applies to new queues by default, so messages are stored unencrypted. Remove the line (the default is " +
68
+ 'encrypted), set it to `true`, or set `kms_master_key_id` (`"alias/aws/sqs"` or a customer managed key).',
69
+ ),
70
+ );
71
+ }
72
+ return out;
73
+ },
74
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * TF036: an EBS volume does not ask for encryption (chant #2288, epic #2284).
3
+ *
4
+ * The Terraform counterpart of the aws lexicon's WAW028, written natively.
5
+ *
6
+ * Absence is not determined for this resource. `aws_ebs_volume.encrypted` is
7
+ * optional, computed and force-new (provider `internal/service/ec2/
8
+ * ebs_volume.go`, v6.67.0). Whether a volume created without it is encrypted
9
+ * depends on the Region's EBS encryption by default
10
+ * (https://docs.aws.amazon.com/ebs/latest/userguide/encryption-by-default.html),
11
+ * which is account state the parse cannot see, and a volume built from
12
+ * `snapshot_id` also follows its snapshot. So an unset `encrypted` emits one
13
+ * `info` diagnostic, not a finding.
14
+ *
15
+ * One configuration does settle absence: the volume `depends_on` an
16
+ * `aws_ebs_encryption_by_default` in the same module whose `enabled` is
17
+ * absent (the provider default is `true`) or literal `true`, on the same
18
+ * `provider` and `region`. The `depends_on` matters: without it Terraform may
19
+ * create the volume before it turns the default on, and the default "has no
20
+ * effect on existing EBS volumes".
21
+ *
22
+ * `encrypted = false` is a finding even though encryption by default would
23
+ * override it. The literal is wrong either way: with the default off the
24
+ * volume is unencrypted, and with it on AWS encrypts the volume anyway ("you
25
+ * cannot disable it for individual volumes"), the volume no longer matches
26
+ * the configuration, and because `encrypted` forces replacement Terraform
27
+ * plans to replace it on every apply.
28
+ *
29
+ * Scope: root and child modules alike (#2112).
30
+ */
31
+
32
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
33
+ import { scopeOfKey } from "../../hcl/parse";
34
+ import type { TerraformBlock } from "./blocks";
35
+ import { finding, isGiven, notDetermined, readBool, resourcesOfType } from "./flat-attributes";
36
+
37
+ const ID = "TF036";
38
+ const DEFAULT_TYPE = "aws_ebs_encryption_by_default";
39
+
40
+ /** The `aws_ebs_encryption_by_default.<name>` addresses a volume's `depends_on` names. */
41
+ function dependsOnDefaults(block: TerraformBlock): string[] {
42
+ const deps = block.body.depends_on;
43
+ if (!Array.isArray(deps)) return [];
44
+ const out: string[] = [];
45
+ for (const d of deps) {
46
+ if (typeof d !== "string") continue;
47
+ const m = /^\$\{\s*(aws_ebs_encryption_by_default\.[A-Za-z_][\w-]*)\s*\}$/.exec(d) ?? /^(aws_ebs_encryption_by_default\.[A-Za-z_][\w-]*)$/.exec(d);
48
+ if (m) out.push(m[1]);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** Same provider configuration and Region, compared as written. */
54
+ function sameTarget(a: TerraformBlock, b: TerraformBlock): boolean {
55
+ return a.body.provider === b.body.provider && a.body.region === b.body.region;
56
+ }
57
+
58
+ export const tf036: PostSynthCheck = {
59
+ id: "TF036",
60
+ description: "EBS volume does not ask for encryption",
61
+
62
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
63
+ const out: PostSynthDiagnostic[] = [];
64
+ const defaults = resourcesOfType(ctx, DEFAULT_TYPE);
65
+
66
+ for (const block of resourcesOfType(ctx, "aws_ebs_volume")) {
67
+ const encrypted = readBool(block.body, "encrypted");
68
+ if (encrypted.kind === "known" && encrypted.value) continue;
69
+ if (encrypted.kind === "known") {
70
+ out.push(
71
+ finding(
72
+ ID,
73
+ "warning",
74
+ block,
75
+ "sets `encrypted = false`. With the Region's EBS encryption by default off, the volume is unencrypted; " +
76
+ "with it on, AWS encrypts the volume anyway and, because `encrypted` forces replacement, Terraform " +
77
+ "plans to replace it on every apply. Set `encrypted = true` (and `kms_key_id` for a customer managed " +
78
+ "key), or remove the line.",
79
+ ),
80
+ );
81
+ continue;
82
+ }
83
+ if (encrypted.kind === "unknown") {
84
+ out.push(
85
+ notDetermined(ID, block, encrypted.reason, "TF036 does not evaluate expressions, so it cannot tell whether this volume is encrypted."),
86
+ );
87
+ continue;
88
+ }
89
+
90
+ const scope = scopeOfKey(block.key);
91
+ const inScope = defaults.filter((d) => scopeOfKey(d.key) === scope && sameTarget(d, block));
92
+ const enabled = inScope.filter((d) => {
93
+ const e = readBool(d.body, "enabled");
94
+ return e.kind === "absent" || (e.kind === "known" && e.value);
95
+ });
96
+ const named = new Set(dependsOnDefaults(block));
97
+ if (enabled.some((d) => named.has(d.address))) continue;
98
+
99
+ const parts = ["does not set `encrypted`"];
100
+ if (isGiven(block.body, "snapshot_id")) parts.push("and is built from `snapshot_id`, whose encryption it inherits");
101
+ const reason =
102
+ `${parts.join(" ")}, so whether it is encrypted depends on the Region's EBS encryption by default, which is ` +
103
+ "account state" +
104
+ (enabled.length > 0
105
+ ? `. \`${enabled[0].address}\` turns the default on in this module, but the volume does not \`depends_on\` it, so it may be created first`
106
+ : "");
107
+ out.push(
108
+ notDetermined(
109
+ ID,
110
+ block,
111
+ reason,
112
+ "Set `encrypted = true` to make the answer explicit, or add `depends_on` naming an enabled " +
113
+ "`aws_ebs_encryption_by_default`.",
114
+ ),
115
+ );
116
+ }
117
+ return out;
118
+ },
119
+ };