@intentius/chant-lexicon-terraform 0.101.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 +3 -3
- 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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TF037: an ECR repository lets an image tag be repointed (chant #2288, epic
|
|
3
|
+
* #2284).
|
|
4
|
+
*
|
|
5
|
+
* The Terraform counterpart of the aws lexicon's WAW054, written natively.
|
|
6
|
+
*
|
|
7
|
+
* Absence is insecure for this resource, and the rule reports it.
|
|
8
|
+
* `aws_ecr_repository.image_tag_mutability` (provider
|
|
9
|
+
* `internal/service/ecr/repository.go`, v6.67.0) is optional with
|
|
10
|
+
* `Default: MUTABLE`, so a repository that does not set it lets anyone with
|
|
11
|
+
* push access move a tag (`prod`, `v1.4.2`) to different image content after
|
|
12
|
+
* it was deployed, scanned or signed.
|
|
13
|
+
*
|
|
14
|
+
* Provider v6 accepts four values (AWS added the two `_WITH_EXCLUSION` modes
|
|
15
|
+
* in 2025), and the rule reads them as follows:
|
|
16
|
+
*
|
|
17
|
+
* - `IMMUTABLE`: secure.
|
|
18
|
+
* - `IMMUTABLE_WITH_EXCLUSION`: secure. Tags matching an
|
|
19
|
+
* `image_tag_mutability_exclusion_filter` stay mutable, a written-down
|
|
20
|
+
* exception such as `latest`. A filter that is nothing but `*` excludes
|
|
21
|
+
* every tag, so it is a finding; a filter from an expression is not
|
|
22
|
+
* determined.
|
|
23
|
+
* - `MUTABLE`, absent, and `MUTABLE_WITH_EXCLUSION` (every tag mutable except
|
|
24
|
+
* those the filters name): a finding.
|
|
25
|
+
* - an expression: not determined.
|
|
26
|
+
*
|
|
27
|
+
* Scope: root and child modules alike (#2112).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
31
|
+
import type { TerraformBlock } from "./blocks";
|
|
32
|
+
import { finding, nestedBodies, notDetermined, readString, resourcesOfType } from "./flat-attributes";
|
|
33
|
+
|
|
34
|
+
const ID = "TF037";
|
|
35
|
+
|
|
36
|
+
const FIX =
|
|
37
|
+
'Set `image_tag_mutability = "IMMUTABLE"` and push a new tag for each build, or `"IMMUTABLE_WITH_EXCLUSION"` with ' +
|
|
38
|
+
"an `image_tag_mutability_exclusion_filter` for the few moving tags (`latest`) that must stay mutable.";
|
|
39
|
+
|
|
40
|
+
const TAIL = "TF037 does not evaluate expressions, so it cannot tell whether this repository's tags are immutable.";
|
|
41
|
+
|
|
42
|
+
/** A diagnostic for an `IMMUTABLE_WITH_EXCLUSION` repository, or nothing. */
|
|
43
|
+
function checkExclusions(block: TerraformBlock): PostSynthDiagnostic | undefined {
|
|
44
|
+
for (const [i, f] of nestedBodies(block.body, "image_tag_mutability_exclusion_filter").entries()) {
|
|
45
|
+
const filter = readString(f, "filter");
|
|
46
|
+
if (filter.kind === "unknown") {
|
|
47
|
+
return notDetermined(ID, block, `exclusion filter ${i + 1}: ${filter.reason}`, TAIL);
|
|
48
|
+
}
|
|
49
|
+
if (filter.kind === "known" && /^\*+$/.test(filter.value)) {
|
|
50
|
+
return finding(
|
|
51
|
+
ID,
|
|
52
|
+
"error",
|
|
53
|
+
block,
|
|
54
|
+
`is \`IMMUTABLE_WITH_EXCLUSION\`, but exclusion filter ${i + 1} is "${filter.value}", which matches every tag, ` +
|
|
55
|
+
`so every tag stays mutable. ${FIX}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (nestedBodies(block.body, "dynamic").some((d) => d.image_tag_mutability_exclusion_filter !== undefined)) {
|
|
60
|
+
return notDetermined(ID, block, 'has a `dynamic "image_tag_mutability_exclusion_filter"` block', TAIL);
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const tf037: PostSynthCheck = {
|
|
66
|
+
id: "TF037",
|
|
67
|
+
description: "ECR repository allows mutable image tags",
|
|
68
|
+
|
|
69
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
70
|
+
const out: PostSynthDiagnostic[] = [];
|
|
71
|
+
for (const block of resourcesOfType(ctx, "aws_ecr_repository")) {
|
|
72
|
+
const mode = readString(block.body, "image_tag_mutability");
|
|
73
|
+
if (mode.kind === "unknown") {
|
|
74
|
+
out.push(notDetermined(ID, block, mode.reason, TAIL));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (mode.kind === "known" && mode.value === "IMMUTABLE") continue;
|
|
78
|
+
if (mode.kind === "known" && mode.value === "IMMUTABLE_WITH_EXCLUSION") {
|
|
79
|
+
const d = checkExclusions(block);
|
|
80
|
+
if (d) out.push(d);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const how =
|
|
84
|
+
mode.kind === "absent"
|
|
85
|
+
? "does not set `image_tag_mutability`, and the provider default is `MUTABLE`"
|
|
86
|
+
: mode.value === "MUTABLE_WITH_EXCLUSION"
|
|
87
|
+
? "is `MUTABLE_WITH_EXCLUSION`, so every tag the exclusion filters do not name stays mutable"
|
|
88
|
+
: `sets \`image_tag_mutability = "${mode.value}"\``;
|
|
89
|
+
out.push(
|
|
90
|
+
finding(
|
|
91
|
+
ID,
|
|
92
|
+
"error",
|
|
93
|
+
block,
|
|
94
|
+
`${how}: a pushed tag can later be repointed at different image content, so what was deployed, scanned or ` +
|
|
95
|
+
`signed under that tag is no longer what it names. ${FIX}`,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
},
|
|
101
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-terraform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.102.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src/",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"generate": "npx tsx src/codegen/generate-cli.ts",
|
|
43
43
|
"validate": "npx tsx src/validate-cli.ts",
|
|
44
44
|
"docs": "npx tsx src/codegen/docs-cli.ts",
|
|
45
|
-
"build": "
|
|
45
|
+
"build": "../../scripts/tsc7.sh -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete",
|
|
46
46
|
"prepack": "npm run generate && npm run bundle && npm run validate && npm run build",
|
|
47
47
|
"bundle": "tsx src/package-cli.ts"
|
|
48
48
|
},
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"access": "public"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@intentius/chant": "^0.
|
|
60
|
+
"@intentius/chant": "^0.102.0",
|
|
61
61
|
"typescript": "^5.9.3",
|
|
62
62
|
"zod": "^4.3.6"
|
|
63
63
|
},
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { hasUnknownKeys, isUnknown, readJsonencode, UNKNOWN, UNKNOWN_KEYS } from "./jsonencode";
|
|
3
|
+
|
|
4
|
+
/** What hcl2json hands back for `attr = jsonencode(<arg>)`: the source text, wrapped as one interpolation. */
|
|
5
|
+
const call = (arg: string): string => `\${jsonencode(${arg})}`;
|
|
6
|
+
|
|
7
|
+
describe("readJsonencode: a literal jsonencode argument read as structure (chant #2286)", () => {
|
|
8
|
+
test("an IAM policy in HCL object syntax, as hcl2json renders it", () => {
|
|
9
|
+
const raw =
|
|
10
|
+
'${jsonencode({\n Version = "2012-10-17"\n Statement = [\n {\n Action = ["s3:*", "ec2:Describe*"]\n' +
|
|
11
|
+
' "Effect" : "Allow"\n Resource = "*"\n },\n ]\n })}';
|
|
12
|
+
expect(readJsonencode(raw)).toEqual({
|
|
13
|
+
kind: "literal",
|
|
14
|
+
value: {
|
|
15
|
+
Version: "2012-10-17",
|
|
16
|
+
Statement: [{ Action: ["s3:*", "ec2:Describe*"], Effect: "Allow", Resource: "*" }],
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a container definitions tuple with commas, numbers, booleans and null", () => {
|
|
22
|
+
const raw = call('[{ name = "app", cpu = 256, essential = true, memory = null, portMappings = [{ containerPort = 80 }] }]');
|
|
23
|
+
expect(readJsonencode(raw)).toEqual({
|
|
24
|
+
kind: "literal",
|
|
25
|
+
value: [{ name: "app", cpu: 256, essential: true, memory: null, portMappings: [{ containerPort: 80 }] }],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("scalars, negative and exponent numbers, and string escapes", () => {
|
|
30
|
+
expect(readJsonencode(call('"x"'))).toEqual({ kind: "literal", value: "x" });
|
|
31
|
+
expect(readJsonencode(call("-1.5e2"))).toEqual({ kind: "literal", value: -150 });
|
|
32
|
+
expect(readJsonencode(call('"a\\"b\\\\c\\n\\u00e9"'))).toEqual({ kind: "literal", value: 'a"b\\c\né' });
|
|
33
|
+
expect(readJsonencode(call('"$${literal} %%{also}"'))).toEqual({ kind: "literal", value: "${literal} %{also}" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("comments inside the argument are whitespace", () => {
|
|
37
|
+
const raw = call('{\n # why\n a = 1 // trailing\n /* block */ b = "x"\n}');
|
|
38
|
+
expect(readJsonencode(raw)).toEqual({ kind: "literal", value: { a: 1, b: "x" } });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("spacing around the call itself", () => {
|
|
42
|
+
expect(readJsonencode('${ jsonencode ( { a = 1 } ) }')).toEqual({ kind: "literal", value: { a: 1 } });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test.each([
|
|
46
|
+
["a reference as a value", '{ Resource = var.arn }', /reference/],
|
|
47
|
+
["a reference as the whole argument", "local.policy", /reference/],
|
|
48
|
+
["a function call", '{ Resource = concat(["a"], var.b) }', /function call/],
|
|
49
|
+
["a for-expression in a tuple", "[for a in var.actions : a]", /for-expression/],
|
|
50
|
+
["a for-expression in an object", "{ for k, v in var.m : k => v }", /for-expression/],
|
|
51
|
+
["a conditional", '{ Action = var.admin ? "*" : "s3:GetObject" }', /reference/],
|
|
52
|
+
["a conditional on a literal", '{ Action = true ? "*" : "x" }', /conditional/],
|
|
53
|
+
["an interpolated string", '{ Resource = "arn:aws:s3:::${var.bucket}/*" }', /interpolation/],
|
|
54
|
+
["a template directive", '{ Resource = "%{ if true }x%{ endif }" }', /directive/],
|
|
55
|
+
["an operator", "{ n = 1 + 2 }", /operator/],
|
|
56
|
+
["a computed key", '{ (var.k) = "v" }', /computed object key/],
|
|
57
|
+
["a heredoc", "<<EOT\n{}\nEOT\n", /heredoc/],
|
|
58
|
+
["an index on a literal", '{ a = ["x"][0] }', /index/],
|
|
59
|
+
])("not-determined: %s", (_name, arg, reason) => {
|
|
60
|
+
const read = readJsonencode(call(arg));
|
|
61
|
+
expect(read.kind).toBe("not-determined");
|
|
62
|
+
if (read.kind === "not-determined") expect(read.reason).toMatch(reason);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("not-determined: two calls in one template, never literal", () => {
|
|
66
|
+
expect(readJsonencode('${jsonencode({a = 1})}-${jsonencode({b = 2})}').kind).toBe("not-determined");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test.each([
|
|
70
|
+
["a plain string", '{"Statement":[]}'],
|
|
71
|
+
["a reference", "${data.aws_iam_policy_document.x.json}"],
|
|
72
|
+
["a file() call", '${file("policy.json")}'],
|
|
73
|
+
["a templatefile() call", '${templatefile("p.tpl", { a = 1 })}'],
|
|
74
|
+
["a template around a jsonencode call", 'x-${jsonencode({a = 1})}'],
|
|
75
|
+
["a number", 3],
|
|
76
|
+
["absent", undefined],
|
|
77
|
+
["a structured value", { Statement: [] }],
|
|
78
|
+
])("not-jsonencode: %s", (_name, raw) => {
|
|
79
|
+
expect(readJsonencode(raw)).toEqual({ kind: "not-jsonencode" });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("readJsonencode with unknownLeaves: expressions become opaque leaves", () => {
|
|
84
|
+
const leaves = (raw: string) => readJsonencode(raw, { unknownLeaves: true });
|
|
85
|
+
|
|
86
|
+
test("a literal Action survives beside a reference Resource", () => {
|
|
87
|
+
const read = leaves(call('{\n Statement = [{\n Action = "*"\n Resource = aws_s3_bucket.x.arn\n Effect = "Allow"\n }]\n}'));
|
|
88
|
+
expect(read.kind).toBe("literal");
|
|
89
|
+
if (read.kind !== "literal") return;
|
|
90
|
+
const stmt = (read.value as { Statement: Record<string, unknown>[] }).Statement[0];
|
|
91
|
+
expect(stmt.Action).toBe("*");
|
|
92
|
+
expect(stmt.Effect).toBe("Allow");
|
|
93
|
+
expect(isUnknown(stmt.Resource)).toBe(true);
|
|
94
|
+
expect((stmt.Resource as { reason: string }).reason).toMatch(/reference/);
|
|
95
|
+
expect((stmt.Resource as Record<symbol, unknown>)[UNKNOWN]).toBe(true);
|
|
96
|
+
expect((stmt.Resource as { source: string }).source).toBe("aws_s3_bucket.x.arn");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("container definitions keep their literal environment beside image = var.image", () => {
|
|
100
|
+
const read = leaves(
|
|
101
|
+
call('[{\n name = "app"\n image = "${var.repo}:${var.tag}"\n cpu = var.cpu * 2\n environment = [{ name = "DB_PASSWORD", value = "hunter2" }]\n}]'),
|
|
102
|
+
);
|
|
103
|
+
expect(read.kind).toBe("literal");
|
|
104
|
+
if (read.kind !== "literal") return;
|
|
105
|
+
const def = (read.value as Record<string, unknown>[])[0];
|
|
106
|
+
expect(def.name).toBe("app");
|
|
107
|
+
expect(isUnknown(def.image)).toBe(true);
|
|
108
|
+
expect((def.image as { source: string }).source).toBe('"${var.repo}:${var.tag}"');
|
|
109
|
+
expect(isUnknown(def.cpu)).toBe(true);
|
|
110
|
+
expect(def.environment).toEqual([{ name: "DB_PASSWORD", value: "hunter2" }]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test.each([
|
|
114
|
+
["a function call spanning lines", 'merge(\n { a = "x" },\n var.extra,\n)'],
|
|
115
|
+
["a conditional with nested literals", 'var.admin ? { a = "*" } : {}'],
|
|
116
|
+
["a heredoc", "<<EOT\n{\"a\": 1}\nEOT"],
|
|
117
|
+
["an indexed tuple", '["x"][0]'],
|
|
118
|
+
["a splat", "aws_s3_bucket.b[*].arn"],
|
|
119
|
+
["a string with nested quotes in its interpolation", '"${lookup(var.m, "k", "}")}"'],
|
|
120
|
+
])("skips %s and reads the next field", (_name, expr) => {
|
|
121
|
+
const read = leaves(call(`{\n a = ${expr}\n b = "after"\n}`));
|
|
122
|
+
expect(read.kind).toBe("literal");
|
|
123
|
+
if (read.kind !== "literal") return;
|
|
124
|
+
const v = read.value as Record<string, unknown>;
|
|
125
|
+
expect(isUnknown(v.a)).toBe(true);
|
|
126
|
+
expect(v.b).toBe("after");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("tuple items are skipped one at a time", () => {
|
|
130
|
+
const read = leaves(call('["s3:GetObject", var.extra, "*"]'));
|
|
131
|
+
expect(read.kind).toBe("literal");
|
|
132
|
+
if (read.kind !== "literal") return;
|
|
133
|
+
const v = read.value as unknown[];
|
|
134
|
+
expect(v[0]).toBe("s3:GetObject");
|
|
135
|
+
expect(isUnknown(v[1])).toBe(true);
|
|
136
|
+
expect(v[2]).toBe("*");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test.each([
|
|
140
|
+
["a tuple for-expression as the whole argument", "[for s in var.s : s]", /for-expression/],
|
|
141
|
+
["an object for-expression as the whole argument", "{ for k, v in var.m : k => v }", /for-expression/],
|
|
142
|
+
["an expression as the whole argument", "local.policy", /reference/],
|
|
143
|
+
["a splat as the whole argument", "var.x[*]", /reference/],
|
|
144
|
+
])("still not-determined: %s", (_name, arg, reason) => {
|
|
145
|
+
const read = leaves(call(arg));
|
|
146
|
+
expect(read.kind).toBe("not-determined");
|
|
147
|
+
if (read.kind === "not-determined") expect(read.reason).toMatch(reason);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("the default mode is unchanged: one expression makes the whole read not-determined", () => {
|
|
151
|
+
expect(readJsonencode(call('{ Action = "*", Resource = var.arn }')).kind).toBe("not-determined");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("readJsonencode with unknownLeaves: members with an expression key", () => {
|
|
156
|
+
test("a traefik label keyed by an interpolation is dropped and recorded (govuk-forms/forms-admin shape)", () => {
|
|
157
|
+
const raw = call(
|
|
158
|
+
'[{\n name = "forms-admin"\n dockerLabels = {\n "traefik.enable" : "true"\n' +
|
|
159
|
+
' "traefik.http.routers.forms-admin-pr-${var.pull_request_number}.rule" : "Host(`pr-${var.pull_request_number}.example`)"\n' +
|
|
160
|
+
' "traefik.port" : "3000"\n }\n}]',
|
|
161
|
+
);
|
|
162
|
+
const read = readJsonencode(raw, { unknownLeaves: true });
|
|
163
|
+
expect(read.kind).toBe("literal");
|
|
164
|
+
if (read.kind !== "literal") return;
|
|
165
|
+
const labels = (read.value as Record<string, unknown>[])[0].dockerLabels as Record<string | symbol, unknown>;
|
|
166
|
+
expect(Object.keys(labels)).toEqual(["traefik.enable", "traefik.port"]);
|
|
167
|
+
expect(hasUnknownKeys(labels)).toBe(true);
|
|
168
|
+
expect(labels[UNKNOWN_KEYS]).toEqual([expect.stringMatching(/object key holding .*interpolation/)]);
|
|
169
|
+
expect(hasUnknownKeys((read.value as Record<string, unknown>[])[0])).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a parenthesised computed key is dropped with its multi-line value", () => {
|
|
173
|
+
const read = readJsonencode(call('{\n (var.k) = {\n x = 1\n }\n b = "after"\n}'), { unknownLeaves: true });
|
|
174
|
+
expect(read.kind).toBe("literal");
|
|
175
|
+
if (read.kind !== "literal") return;
|
|
176
|
+
expect(Object.keys(read.value as object)).toEqual(["b"]);
|
|
177
|
+
expect((read.value as Record<symbol, unknown>)[UNKNOWN_KEYS]).toEqual(["a computed object key"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test.each([
|
|
181
|
+
["a computed key", '{ (var.k) = "v" }', /computed object key/],
|
|
182
|
+
["an interpolated key", '{ "${var.k}" = "v" }', /object key/],
|
|
183
|
+
])("default mode: %s makes the whole read not-determined", (_name, arg, reason) => {
|
|
184
|
+
const read = readJsonencode(call(arg));
|
|
185
|
+
expect(read.kind).toBe("not-determined");
|
|
186
|
+
if (read.kind === "not-determined") expect(read.reason).toMatch(reason);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("readJsonencode with unknownLeaves: a for-expression below the argument is a leaf", () => {
|
|
191
|
+
test("a tuple for-expression as a member value (sonodar/dify-aws-terraform shape)", () => {
|
|
192
|
+
const raw = call(
|
|
193
|
+
'[{\n name = "api"\n environment = [for name, value in {\n MODE = "api"\n LOG_LEVEL = "INFO"\n } : { name = name, value = tostring(value) }]\n essential = true\n}]',
|
|
194
|
+
);
|
|
195
|
+
const read = readJsonencode(raw, { unknownLeaves: true });
|
|
196
|
+
expect(read.kind).toBe("literal");
|
|
197
|
+
if (read.kind !== "literal") return;
|
|
198
|
+
const def = (read.value as Record<string, unknown>[])[0];
|
|
199
|
+
expect(def.name).toBe("api");
|
|
200
|
+
expect(isUnknown(def.environment)).toBe(true);
|
|
201
|
+
expect((def.environment as { reason: string }).reason).toMatch(/for-expression/);
|
|
202
|
+
expect(def.essential).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("an object for-expression as a member value", () => {
|
|
206
|
+
const read = readJsonencode(call('{\n labels = { for k, v in var.m : k => v }\n b = 1\n}'), { unknownLeaves: true });
|
|
207
|
+
expect(read.kind).toBe("literal");
|
|
208
|
+
if (read.kind !== "literal") return;
|
|
209
|
+
const v = read.value as Record<string, unknown>;
|
|
210
|
+
expect(isUnknown(v.labels)).toBe(true);
|
|
211
|
+
expect(v.b).toBe(1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a for-expression producing a policy's Statement list is one unknown leaf", () => {
|
|
215
|
+
const read = readJsonencode(call('{\n Version = "2012-10-17"\n Statement = [for s in var.statements : s]\n}'), { unknownLeaves: true });
|
|
216
|
+
expect(read.kind).toBe("literal");
|
|
217
|
+
if (read.kind !== "literal") return;
|
|
218
|
+
expect(isUnknown((read.value as Record<string, unknown>).Statement)).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a for-expression as a tuple item", () => {
|
|
222
|
+
const read = readJsonencode(call('["a", [for x in var.y : x]]'), { unknownLeaves: true });
|
|
223
|
+
expect(read.kind).toBe("literal");
|
|
224
|
+
if (read.kind === "literal") expect(isUnknown((read.value as unknown[])[1])).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|