@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.
- package/dist/codegen/package.d.ts +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/hcl/jsonencode.d.ts +106 -0
- package/dist/hcl/jsonencode.d.ts.map +1 -0
- package/dist/hcl/marker.d.ts.map +1 -1
- package/dist/integrity.json +11 -2
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +1 -1
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/flat-attributes.d.ts +69 -0
- package/dist/lint/post-synth/flat-attributes.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/tf030.d.ts +51 -0
- package/dist/lint/post-synth/tf030.d.ts.map +1 -0
- package/dist/lint/post-synth/tf031.d.ts +55 -0
- package/dist/lint/post-synth/tf031.d.ts.map +1 -0
- package/dist/lint/post-synth/tf032.d.ts +71 -0
- package/dist/lint/post-synth/tf032.d.ts.map +1 -0
- package/dist/lint/post-synth/tf033.d.ts +50 -0
- package/dist/lint/post-synth/tf033.d.ts.map +1 -0
- package/dist/lint/post-synth/tf034.d.ts +25 -0
- package/dist/lint/post-synth/tf034.d.ts.map +1 -0
- package/dist/lint/post-synth/tf035.d.ts +33 -0
- package/dist/lint/post-synth/tf035.d.ts.map +1 -0
- package/dist/lint/post-synth/tf036.d.ts +33 -0
- package/dist/lint/post-synth/tf036.d.ts.map +1 -0
- package/dist/lint/post-synth/tf037.d.ts +31 -0
- package/dist/lint/post-synth/tf037.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +8 -0
- package/dist/okf/rules/TF030.md +11 -0
- package/dist/okf/rules/TF031.md +11 -0
- package/dist/okf/rules/TF032.md +11 -0
- package/dist/okf/rules/TF033.md +11 -0
- package/dist/okf/rules/TF034.md +11 -0
- package/dist/okf/rules/TF035.md +11 -0
- package/dist/okf/rules/TF036.md +11 -0
- package/dist/okf/rules/TF037.md +11 -0
- package/dist/op/activity-contracts.d.ts +18 -18
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/rules/flat-attributes.ts +146 -0
- package/dist/rules/tf030.ts +290 -0
- package/dist/rules/tf031.ts +370 -0
- package/dist/rules/tf032.ts +226 -0
- package/dist/rules/tf033.ts +160 -0
- package/dist/rules/tf034.ts +63 -0
- package/dist/rules/tf035.ts +74 -0
- package/dist/rules/tf036.ts +119 -0
- package/dist/rules/tf037.ts +101 -0
- package/package.json +3 -3
- package/src/hcl/jsonencode.test.ts +226 -0
- package/src/hcl/jsonencode.ts +454 -0
- package/src/lint/audit-catalog.ts +148 -0
- package/src/lint/audit-lineage.ts +155 -1
- package/src/lint/post-synth/estate-2284.test.ts +68 -0
- package/src/lint/post-synth/fixtures/TF030/negative-rules.tf +45 -0
- package/src/lint/post-synth/fixtures/TF030/negative.tf +55 -0
- package/src/lint/post-synth/fixtures/TF030/not-determined-port.tf +28 -0
- package/src/lint/post-synth/fixtures/TF030/not-determined-standalone.tf +32 -0
- package/src/lint/post-synth/fixtures/TF030/not-determined-vpc-rule.tf +21 -0
- package/src/lint/post-synth/fixtures/TF030/not-determined.tf +39 -0
- package/src/lint/post-synth/fixtures/TF030/positive-ipv6.tf +44 -0
- package/src/lint/post-synth/fixtures/TF030/positive-mixed.tf +36 -0
- package/src/lint/post-synth/fixtures/TF030/positive-standalone.tf +25 -0
- package/src/lint/post-synth/fixtures/TF030/positive.tf +43 -0
- package/src/lint/post-synth/fixtures/TF031/negative.tf +89 -0
- package/src/lint/post-synth/fixtures/TF031/not-determined.tf +139 -0
- package/src/lint/post-synth/fixtures/TF031/positive-data-source.tf +57 -0
- package/src/lint/post-synth/fixtures/TF031/positive-heredoc.tf +149 -0
- package/src/lint/post-synth/fixtures/TF031/positive-jsonencode.tf +115 -0
- package/src/lint/post-synth/fixtures/TF032/negative-heredoc.tf +20 -0
- package/src/lint/post-synth/fixtures/TF032/negative-references.tf +214 -0
- package/src/lint/post-synth/fixtures/TF032/negative.tf +99 -0
- package/src/lint/post-synth/fixtures/TF032/positive-heredoc.tf +22 -0
- package/src/lint/post-synth/fixtures/TF032/positive-sidecar.tf +197 -0
- package/src/lint/post-synth/fixtures/TF032/positive.tf +54 -0
- package/src/lint/post-synth/fixtures/TF033/negative.tf +39 -0
- package/src/lint/post-synth/fixtures/TF033/not-determined-aurora.tf +25 -0
- package/src/lint/post-synth/fixtures/TF033/positive-absent.tf +93 -0
- package/src/lint/post-synth/fixtures/TF033/positive.tf +50 -0
- package/src/lint/post-synth/fixtures/TF034/negative.tf +24 -0
- package/src/lint/post-synth/fixtures/TF034/positive-absent.tf +36 -0
- package/src/lint/post-synth/fixtures/TF034/positive.tf +14 -0
- package/src/lint/post-synth/fixtures/TF035/negative-absent.tf +43 -0
- package/src/lint/post-synth/fixtures/TF035/negative.tf +54 -0
- package/src/lint/post-synth/fixtures/TF035/positive.tf +37 -0
- package/src/lint/post-synth/fixtures/TF036/negative.tf +61 -0
- package/src/lint/post-synth/fixtures/TF036/not-determined.tf +28 -0
- package/src/lint/post-synth/fixtures/TF036/positive.tf +38 -0
- package/src/lint/post-synth/fixtures/TF037/negative-exclusion.tf +63 -0
- package/src/lint/post-synth/fixtures/TF037/negative.tf +27 -0
- package/src/lint/post-synth/fixtures/TF037/not-determined.tf +27 -0
- package/src/lint/post-synth/fixtures/TF037/positive-absent.tf +15 -0
- package/src/lint/post-synth/fixtures/TF037/positive.tf +26 -0
- package/src/lint/post-synth/fixtures/estate-2284/clean/main.tf +132 -0
- package/src/lint/post-synth/fixtures/estate-2284/violating/main.tf +112 -0
- package/src/lint/post-synth/flat-attributes.ts +146 -0
- package/src/lint/post-synth/index.ts +16 -0
- package/src/lint/post-synth/tf030.test.ts +219 -0
- package/src/lint/post-synth/tf030.ts +290 -0
- package/src/lint/post-synth/tf031.test.ts +229 -0
- package/src/lint/post-synth/tf031.ts +370 -0
- package/src/lint/post-synth/tf032.test.ts +127 -0
- package/src/lint/post-synth/tf032.ts +226 -0
- package/src/lint/post-synth/tf033.test.ts +126 -0
- package/src/lint/post-synth/tf033.ts +160 -0
- package/src/lint/post-synth/tf034.test.ts +71 -0
- package/src/lint/post-synth/tf034.ts +63 -0
- package/src/lint/post-synth/tf035.test.ts +68 -0
- package/src/lint/post-synth/tf035.ts +74 -0
- package/src/lint/post-synth/tf036.test.ts +87 -0
- package/src/lint/post-synth/tf036.ts +119 -0
- package/src/lint/post-synth/tf037.test.ts +88 -0
- package/src/lint/post-synth/tf037.ts +101 -0
- 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
|
+
};
|