@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,132 @@
1
+ # Epic #2284 acceptance estate, clean root. Written for chant, not vendored.
2
+ # The same estate as ../violating with every finding fixed, and with the
3
+ # provider-default cases left as real estates leave them: an SQS queue that
4
+ # sets no encryption attribute (encrypted by AWS's SSE-SQS default), an EBS
5
+ # volume that leaves `encrypted` unset but depends on an enabled
6
+ # `aws_ebs_encryption_by_default`, an SNS topic on the AWS managed key, and an
7
+ # ECR repository that keeps `latest` mutable through an exclusion filter.
8
+ # TF030 to TF037 report nothing here (src/lint/post-synth/estate-2284.test.ts).
9
+
10
+ terraform {
11
+ required_version = ">= 1.5.0"
12
+
13
+ backend "s3" {
14
+ bucket = "acme-terraform-state"
15
+ key = "payments/terraform.tfstate"
16
+ region = "eu-west-1"
17
+ }
18
+
19
+ required_providers {
20
+ aws = {
21
+ source = "hashicorp/aws"
22
+ version = "~> 6.0"
23
+ }
24
+ }
25
+ }
26
+
27
+ provider "aws" {
28
+ region = "eu-west-1"
29
+ }
30
+
31
+ # TF030: HTTPS from anywhere, SSH from the VPC only.
32
+ resource "aws_security_group" "web" {
33
+ name = "web"
34
+ vpc_id = "vpc-0a1b2c3d4e5f60718"
35
+
36
+ ingress {
37
+ from_port = 443
38
+ to_port = 443
39
+ protocol = "tcp"
40
+ cidr_blocks = ["0.0.0.0/0"]
41
+ }
42
+
43
+ ingress {
44
+ from_port = 22
45
+ to_port = 22
46
+ protocol = "tcp"
47
+ cidr_blocks = ["10.0.0.0/8"]
48
+ }
49
+ }
50
+
51
+ # TF031: named actions on named resources.
52
+ data "aws_iam_policy_document" "deployer" {
53
+ statement {
54
+ actions = ["ecs:UpdateService", "ecs:DescribeServices"]
55
+ resources = [aws_ecs_task_definition.api.arn]
56
+ }
57
+ }
58
+
59
+ resource "aws_iam_policy" "deployer" {
60
+ name = "deployer"
61
+ policy = data.aws_iam_policy_document.deployer.json
62
+ }
63
+
64
+ # TF032: the password comes from Secrets Manager.
65
+ resource "aws_ecs_task_definition" "api" {
66
+ family = "payments-api"
67
+ requires_compatibilities = ["FARGATE"]
68
+ network_mode = "awsvpc"
69
+ cpu = "512"
70
+ memory = "1024"
71
+
72
+ container_definitions = jsonencode([{
73
+ name = "api"
74
+ image = "public.ecr.aws/acme/payments-api:1.4.2"
75
+ environment = [
76
+ { name = "LOG_LEVEL", value = "info" }
77
+ ]
78
+ secrets = [
79
+ { name = "DB_PASSWORD", valueFrom = "arn:aws:secretsmanager:eu-west-1:111122223333:secret:ledger-db" }
80
+ ]
81
+ }])
82
+ }
83
+
84
+ # TF033: encrypted storage.
85
+ resource "aws_db_instance" "ledger" {
86
+ identifier = "ledger"
87
+ engine = "postgres"
88
+ instance_class = "db.t4g.medium"
89
+ allocated_storage = 50
90
+ username = "ledger"
91
+ password = var.db_password
92
+ storage_encrypted = true
93
+ skip_final_snapshot = true
94
+ }
95
+
96
+ variable "db_password" {
97
+ type = string
98
+ sensitive = true
99
+ }
100
+
101
+ # TF034: the AWS managed key.
102
+ resource "aws_sns_topic" "alerts" {
103
+ name = "payments-alerts"
104
+ kms_master_key_id = "alias/aws/sns"
105
+ }
106
+
107
+ # TF035: provider default. No encryption attribute; SQS encrypts new queues
108
+ # with SSE-SQS.
109
+ resource "aws_sqs_queue" "settlements" {
110
+ name = "settlements"
111
+ }
112
+
113
+ # TF036: provider default. `encrypted` unset, the Region's encryption by
114
+ # default turned on first.
115
+ resource "aws_ebs_encryption_by_default" "on" {}
116
+
117
+ resource "aws_ebs_volume" "scratch" {
118
+ availability_zone = "eu-west-1a"
119
+ size = 100
120
+ depends_on = [aws_ebs_encryption_by_default.on]
121
+ }
122
+
123
+ # TF037: immutable tags, with `latest` as the one written-down exception.
124
+ resource "aws_ecr_repository" "api" {
125
+ name = "payments-api"
126
+ image_tag_mutability = "IMMUTABLE_WITH_EXCLUSION"
127
+
128
+ image_tag_mutability_exclusion_filter {
129
+ filter = "latest"
130
+ filter_type = "WILDCARD"
131
+ }
132
+ }
@@ -0,0 +1,112 @@
1
+ # Epic #2284 acceptance estate, violating root. Written for chant, not
2
+ # vendored: the fidelity probe's fixtures (docs/design/waw-hcl-fidelity-probe.md)
3
+ # were never committed, so this root carries one violation per rule, TF030 to
4
+ # TF037, each written the way a real estate writes it. `chant audit` over this
5
+ # directory reports every one of them (src/lint/post-synth/estate-2284.test.ts).
6
+
7
+ terraform {
8
+ required_version = ">= 1.5.0"
9
+
10
+ backend "s3" {
11
+ bucket = "acme-terraform-state"
12
+ key = "payments/terraform.tfstate"
13
+ region = "eu-west-1"
14
+ }
15
+
16
+ required_providers {
17
+ aws = {
18
+ source = "hashicorp/aws"
19
+ version = "~> 6.0"
20
+ }
21
+ }
22
+ }
23
+
24
+ provider "aws" {
25
+ region = "eu-west-1"
26
+ }
27
+
28
+ # TF030: SSH open to the world.
29
+ resource "aws_security_group" "bastion" {
30
+ name = "bastion"
31
+ vpc_id = "vpc-0a1b2c3d4e5f60718"
32
+
33
+ ingress {
34
+ from_port = 22
35
+ to_port = 22
36
+ protocol = "tcp"
37
+ cidr_blocks = ["0.0.0.0/0"]
38
+ }
39
+ }
40
+
41
+ # TF031: an admin policy.
42
+ resource "aws_iam_policy" "deployer" {
43
+ name = "deployer"
44
+ policy = jsonencode({
45
+ Version = "2012-10-17"
46
+ Statement = [
47
+ {
48
+ Effect = "Allow"
49
+ Action = "*"
50
+ Resource = "*"
51
+ },
52
+ ]
53
+ })
54
+ }
55
+
56
+ # TF032: a database password in a container's environment.
57
+ resource "aws_ecs_task_definition" "api" {
58
+ family = "payments-api"
59
+ requires_compatibilities = ["FARGATE"]
60
+ network_mode = "awsvpc"
61
+ cpu = "512"
62
+ memory = "1024"
63
+
64
+ container_definitions = jsonencode([{
65
+ name = "api"
66
+ image = "public.ecr.aws/acme/payments-api:1.4.2"
67
+ environment = [
68
+ { name = "LOG_LEVEL", value = "info" },
69
+ { name = "DB_PASSWORD", value = "Pm-Pr0d-Db-2026!" }
70
+ ]
71
+ }])
72
+ }
73
+
74
+ # TF033: storage encryption written off.
75
+ resource "aws_db_instance" "ledger" {
76
+ identifier = "ledger"
77
+ engine = "postgres"
78
+ instance_class = "db.t4g.medium"
79
+ allocated_storage = 50
80
+ username = "ledger"
81
+ password = var.db_password
82
+ storage_encrypted = false
83
+ skip_final_snapshot = true
84
+ }
85
+
86
+ variable "db_password" {
87
+ type = string
88
+ sensitive = true
89
+ }
90
+
91
+ # TF034: a topic that names no key.
92
+ resource "aws_sns_topic" "alerts" {
93
+ name = "payments-alerts"
94
+ }
95
+
96
+ # TF035: SSE-SQS turned off.
97
+ resource "aws_sqs_queue" "settlements" {
98
+ name = "settlements"
99
+ sqs_managed_sse_enabled = false
100
+ }
101
+
102
+ # TF036: a volume that asks not to be encrypted.
103
+ resource "aws_ebs_volume" "scratch" {
104
+ availability_zone = "eu-west-1a"
105
+ size = 100
106
+ encrypted = false
107
+ }
108
+
109
+ # TF037: a repository left at the provider default, MUTABLE.
110
+ resource "aws_ecr_repository" "api" {
111
+ name = "payments-api"
112
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Shared reading helpers for TF033-TF037, the five flat encryption and
3
+ * immutability rules (chant #2288, epic #2284).
4
+ *
5
+ * Each of those rules reads one or two scalar attributes of one resource type
6
+ * and answers a three-valued question: the literal proves the resource
7
+ * secure, the literal proves it insecure, or the parse cannot tell. This
8
+ * module is the reading half; each rule file holds its own answer for
9
+ * absence, because that answer differs per resource (see each rule's doc
10
+ * comment and its section of `docs/pages/lint-rules.mdx`). It exports no
11
+ * `PostSynthCheck`, so the generated barrel skips it.
12
+ */
13
+
14
+ import type { PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
15
+ import { RESOURCE_TYPE, type BlockBody } from "../../hcl/parse";
16
+ import { attr } from "../../hcl/value";
17
+ import { blocksOfType, nestedBodies, type TerraformBlock } from "./blocks";
18
+
19
+ /** What one attribute read settles. `reason` says why a value could not be read. */
20
+ export type Read<T> = { kind: "known"; value: T } | { kind: "absent" } | { kind: "unknown"; reason: string };
21
+
22
+ /** Every `resource` block of one provider type (`aws_sqs_queue`), in entity order. */
23
+ export function resourcesOfType(ctx: PostSynthContext, type: string): TerraformBlock[] {
24
+ return blocksOfType(ctx, RESOURCE_TYPE).filter((b) => b.address.startsWith(`${type}.`));
25
+ }
26
+
27
+ /** One expression, for a message: `var.encrypted`, or the template text. */
28
+ function describe(name: string, raw: unknown, refs: string[] | undefined, kind: "reference" | "template"): string {
29
+ return kind === "reference"
30
+ ? `\`${name}\` is an expression (${refs?.[0] ?? String(raw)})`
31
+ : `\`${name}\` is a template string ("${String(raw)}")`;
32
+ }
33
+
34
+ /**
35
+ * A bool attribute. Terraform converts the strings `"true"` and `"false"` to
36
+ * bool, so those read as the bool they convert to. Any other literal (a
37
+ * number, a list) is a plan-time type error, not this rule's concern, and
38
+ * reads as unknown.
39
+ */
40
+ export function readBool(body: BlockBody, name: string): Read<boolean> {
41
+ const a = attr(body, name);
42
+ if (a.kind === "absent") return { kind: "absent" };
43
+ if (a.kind === "reference" || a.kind === "template") {
44
+ return { kind: "unknown", reason: describe(name, a.raw, a.refs, a.kind) };
45
+ }
46
+ if (typeof a.value === "boolean") return { kind: "known", value: a.value };
47
+ if (a.value === "true" || a.value === "false") return { kind: "known", value: a.value === "true" };
48
+ return { kind: "unknown", reason: `\`${name}\` is not a bool (${JSON.stringify(a.value)})` };
49
+ }
50
+
51
+ /** A string attribute, literal or not. */
52
+ export function readString(body: BlockBody, name: string): Read<string> {
53
+ const a = attr(body, name);
54
+ if (a.kind === "absent") return { kind: "absent" };
55
+ if (a.kind === "reference" || a.kind === "template") {
56
+ return { kind: "unknown", reason: describe(name, a.raw, a.refs, a.kind) };
57
+ }
58
+ if (typeof a.value === "string") return { kind: "known", value: a.value };
59
+ return { kind: "unknown", reason: `\`${name}\` is not a string (${JSON.stringify(a.value)})` };
60
+ }
61
+
62
+ /** Expression roots that name an input or a computed value, not a managed object. */
63
+ const VALUE_ROOTS = new Set(["var", "local", "module", "each", "count", "path", "terraform", "self"]);
64
+
65
+ /** `aws_kms_key.sns.arn`, `data.aws_kms_alias.sqs.target_key_arn`, with an optional index or splat. */
66
+ const OBJECT_ATTRIBUTE = /^(?:data\.)?([a-z][a-z0-9_]*)\.[A-Za-z_][\w-]*(?:\[[^\]]*\])?(?:\.[A-Za-z_][\w-]*|\[[^\]]*\])+$/;
67
+
68
+ /** Every `${...}` span in a template, as `attr()` finds them. */
69
+ const SPANS = /\$\{(?:[^{}]|\{[^{}]*\})*\}/g;
70
+
71
+ /**
72
+ * Whether a key-like argument (`kms_master_key_id`) is set to something.
73
+ *
74
+ * - absent, or `null`: `absent`.
75
+ * - a literal: `known`, with the string (`""` is a value, and a rule decides
76
+ * what an empty key means).
77
+ * - an attribute of a managed resource or data source (`aws_kms_key.x.arn`):
78
+ * `known` and non-empty. The provider computes a key's id or ARN; it is
79
+ * never the empty string, so the key is set whatever its value turns out to
80
+ * be.
81
+ * - a template with literal text (`"alias/${var.env}-sns"`): `known` and
82
+ * non-empty, for the same reason.
83
+ * - anything else (`var.kms_key`, `local.key`, a conditional, a function
84
+ * call): `unknown`. A variable can be `null` or `""`.
85
+ */
86
+ export function readKeySet(body: BlockBody, name: string): Read<string> {
87
+ const a = attr(body, name);
88
+ if (a.kind === "absent") return { kind: "absent" };
89
+ if (a.kind === "literal") {
90
+ return typeof a.value === "string"
91
+ ? { kind: "known", value: a.value }
92
+ : { kind: "unknown", reason: `\`${name}\` is not a string (${JSON.stringify(a.value)})` };
93
+ }
94
+ const raw = String(a.raw);
95
+ if (a.kind === "template") {
96
+ if (raw.replace(SPANS, "").length > 0) return { kind: "known", value: raw };
97
+ return { kind: "unknown", reason: describe(name, a.raw, a.refs, "template") };
98
+ }
99
+ const expr = (a.refs?.[0] ?? "").trim();
100
+ const m = OBJECT_ATTRIBUTE.exec(expr);
101
+ if (m && !VALUE_ROOTS.has(m[1])) return { kind: "known", value: raw };
102
+ return { kind: "unknown", reason: describe(name, a.raw, a.refs, "reference") };
103
+ }
104
+
105
+ /**
106
+ * Whether an argument that names a source (`snapshot_identifier`,
107
+ * `replicate_source_db`) or a nested block (`restore_to_point_in_time`) is
108
+ * given. The provider reads these with `GetOk`, so a literal `""` is the same
109
+ * as leaving the argument out; anything else, a reference included, is given.
110
+ */
111
+ export function isGiven(body: BlockBody, name: string): boolean {
112
+ const a = attr(body, name);
113
+ if (a.kind === "absent") return false;
114
+ if (a.kind === "literal") {
115
+ if (a.value === "" || a.value === false) return false;
116
+ if (Array.isArray(a.value)) return a.value.length > 0;
117
+ }
118
+ return true;
119
+ }
120
+
121
+ /** The bodies of a nested block, re-exported for the rule files. */
122
+ export { nestedBodies };
123
+
124
+ /** A finding the literal proves. */
125
+ export function finding(
126
+ checkId: string,
127
+ severity: "error" | "warning",
128
+ block: TerraformBlock,
129
+ message: string,
130
+ ): PostSynthDiagnostic {
131
+ return { checkId, severity, message: `"${block.address}" ${message}`, entity: block.key, lexicon: "terraform" };
132
+ }
133
+
134
+ /**
135
+ * The one `info` diagnostic a rule emits when it cannot read the value it
136
+ * needs. The message starts `Not determined:`, as #2284 fixed for TF030-TF037.
137
+ */
138
+ export function notDetermined(checkId: string, block: TerraformBlock, reason: string, tail: string): PostSynthDiagnostic {
139
+ return {
140
+ checkId,
141
+ severity: "info",
142
+ message: `Not determined: "${block.address}" ${reason}. ${tail}`,
143
+ entity: block.key,
144
+ lexicon: "terraform",
145
+ };
146
+ }
@@ -28,6 +28,14 @@ import { tf026 } from "./tf026";
28
28
  import { tf027 } from "./tf027";
29
29
  import { tf028 } from "./tf028";
30
30
  import { tf029 } from "./tf029";
31
+ import { tf030 } from "./tf030";
32
+ import { tf031 } from "./tf031";
33
+ import { tf032 } from "./tf032";
34
+ import { tf033 } from "./tf033";
35
+ import { tf034 } from "./tf034";
36
+ import { tf035 } from "./tf035";
37
+ import { tf036 } from "./tf036";
38
+ import { tf037 } from "./tf037";
31
39
 
32
40
  export const postSynthChecks: PostSynthCheck[] = [
33
41
  tf001,
@@ -58,4 +66,12 @@ export const postSynthChecks: PostSynthCheck[] = [
58
66
  tf027,
59
67
  tf028,
60
68
  tf029,
69
+ tf030,
70
+ tf031,
71
+ tf032,
72
+ tf033,
73
+ tf034,
74
+ tf035,
75
+ tf036,
76
+ tf037,
61
77
  ];
@@ -0,0 +1,219 @@
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 { tf030 } from "./tf030";
6
+
7
+ async function run(name: string): Promise<PostSynthDiagnostic[]> {
8
+ return tf030.check(await loadFixture("TF030", name));
9
+ }
10
+
11
+ /** For the shapes no vendored fixture carries: parse one inline source. */
12
+ async function runInline(source: string): Promise<PostSynthDiagnostic[]> {
13
+ const entities = await blocksToEntities([{ name: "main.tf", source }], "inline");
14
+ return tf030.check({ outputs: new Map(), entities } as unknown as PostSynthContext);
15
+ }
16
+
17
+ const errors = (d: PostSynthDiagnostic[]) => d.filter((x) => x.severity === "error");
18
+ const infos = (d: PostSynthDiagnostic[]) => d.filter((x) => x.severity === "info");
19
+
20
+ describe("TF030: unrestricted ingress on a sensitive port", () => {
21
+ describe("fires (vendored fixtures)", () => {
22
+ test("a nested ingress block opening SSH to 0.0.0.0/0, and not the 8080 rule beside it", async () => {
23
+ const d = await run("positive-mixed");
24
+ expect(d).toHaveLength(1);
25
+ expect(d[0].checkId).toBe("TF030");
26
+ expect(d[0].severity).toBe("error");
27
+ expect(d[0].entity).toBe("TF030/aws_security_group.jenkins_port");
28
+ expect(d[0].message).toContain('"aws_security_group.jenkins_port" ingress rule 2');
29
+ expect(d[0].message).toContain("0.0.0.0/0 on port 22 (SSH)");
30
+ });
31
+
32
+ test("a port range spanning sensitive ports, and not the 443 rule", async () => {
33
+ const d = await run("positive");
34
+ expect(d).toHaveLength(1);
35
+ expect(d[0].severity).toBe("error");
36
+ expect(d[0].message).toContain("ingress rule 2");
37
+ expect(d[0].message).toContain("ports 1025-65535, which include MySQL (3306), RDP (3389) and PostgreSQL (5432)");
38
+ });
39
+
40
+ test("standalone aws_security_group_rule and aws_vpc_security_group_ingress_rule", async () => {
41
+ const d = await run("positive-standalone");
42
+ expect(errors(d).map((x) => x.entity).sort()).toEqual([
43
+ "TF030/aws_security_group_rule.fail",
44
+ "TF030/aws_vpc_security_group_ingress_rule.fail",
45
+ ]);
46
+ expect(d).toHaveLength(2);
47
+ });
48
+
49
+ test("::/0 on IPv6, and protocol -1 in attribute syntax reaching every port", async () => {
50
+ const d = await run("positive-ipv6");
51
+ expect(d).toHaveLength(2);
52
+ const byEntity = new Map(d.map((x) => [x.entity, x]));
53
+ expect(byEntity.get("TF030/aws_security_group.fail-ipv6")?.message).toContain("from ::/0 on port 22");
54
+ expect(byEntity.get("TF030/aws_security_group.fail4")?.message).toContain("every port (protocol -1)");
55
+ });
56
+ });
57
+
58
+ describe("silent (vendored fixtures)", () => {
59
+ test("SSH open only to private ranges", async () => {
60
+ expect(await run("negative")).toEqual([]);
61
+ });
62
+
63
+ test("no ingress at all, ICMP from anywhere, SSH from a security group, open egress", async () => {
64
+ expect(await run("negative-rules")).toEqual([]);
65
+ });
66
+ });
67
+
68
+ describe("not determined (vendored fixtures)", () => {
69
+ test("cidr_blocks = var.allowed_ssh_cidrs on port 22 is an info, not an error", async () => {
70
+ const d = await run("not-determined");
71
+ expect(errors(d)).toEqual([]);
72
+ expect(infos(d)).toHaveLength(1);
73
+ expect(d[0].message).toMatch(/^Not determined: /);
74
+ expect(d[0].message).toContain("`cidr_blocks` is `var.allowed_ssh_cidrs`");
75
+ });
76
+
77
+ test("an open CIDR on from_port/to_port = var.ssh_port is an info naming both", async () => {
78
+ const d = await run("not-determined-port");
79
+ expect(d).toHaveLength(1);
80
+ expect(d[0].severity).toBe("info");
81
+ expect(d[0].message).toContain("`from_port` is `var.ssh_port`; `to_port` is `var.ssh_port`");
82
+ });
83
+
84
+ test("a standalone rule with a variable CIDR is an info; the open egress rule is silent", async () => {
85
+ const d = await run("not-determined-standalone");
86
+ expect(d).toHaveLength(1);
87
+ expect(d[0].severity).toBe("info");
88
+ expect(d[0].entity).toBe("TF030/aws_security_group_rule.allow_ssh_internal");
89
+ expect(d[0].message).toContain("an element of `cidr_blocks` is `var.cidr_vpc`");
90
+ });
91
+
92
+ test("a for_each over each.value is an info, even though its locals open SSH to the world", async () => {
93
+ const d = await run("not-determined-vpc-rule");
94
+ expect(d).toHaveLength(1);
95
+ expect(d[0].severity).toBe("info");
96
+ expect(d[0].message).toContain("`cidr_ipv4` is `each.value.cidr_ipv4`");
97
+ expect(d[0].message).toContain("`ip_protocol` is `each.value.ip_protocol`");
98
+ });
99
+ });
100
+
101
+ describe("edge cases (inline)", () => {
102
+ test("a known-safe fact settles the rule even beside an unreadable one", async () => {
103
+ const d = await runInline(`
104
+ resource "aws_security_group_rule" "https" {
105
+ type = "ingress"
106
+ from_port = 443
107
+ to_port = 443
108
+ protocol = "tcp"
109
+ cidr_blocks = var.allowed
110
+ }
111
+ resource "aws_security_group_rule" "private" {
112
+ type = "ingress"
113
+ from_port = var.port
114
+ to_port = var.port
115
+ protocol = "tcp"
116
+ cidr_blocks = ["10.0.0.0/8"]
117
+ }`);
118
+ expect(d).toEqual([]);
119
+ });
120
+
121
+ test("egress and an unknown type", async () => {
122
+ const d = await runInline(`
123
+ resource "aws_security_group_rule" "out" {
124
+ type = "egress"
125
+ from_port = 22
126
+ to_port = 22
127
+ protocol = "tcp"
128
+ cidr_blocks = ["0.0.0.0/0"]
129
+ }
130
+ resource "aws_security_group_rule" "either" {
131
+ type = var.direction
132
+ from_port = 22
133
+ to_port = 22
134
+ protocol = "tcp"
135
+ cidr_blocks = ["0.0.0.0/0"]
136
+ }`);
137
+ expect(d).toHaveLength(1);
138
+ expect(d[0].severity).toBe("info");
139
+ expect(d[0].message).toContain("`type` is `var.direction`");
140
+ });
141
+
142
+ test("protocol written as the number -1, a quoted port, and upper-case TCP", async () => {
143
+ const d = await runInline(`
144
+ resource "aws_security_group" "a" {
145
+ ingress {
146
+ from_port = 0
147
+ to_port = 0
148
+ protocol = -1
149
+ cidr_blocks = ["0.0.0.0/0"]
150
+ }
151
+ ingress {
152
+ from_port = "5432"
153
+ to_port = "5432"
154
+ protocol = "TCP"
155
+ ipv6_cidr_blocks = ["::/0"]
156
+ }
157
+ }`);
158
+ expect(errors(d)).toHaveLength(2);
159
+ expect(d[1].message).toContain("port 5432 (PostgreSQL)");
160
+ });
161
+
162
+ test("tcp 0-0 is port 0, not every port", async () => {
163
+ const d = await runInline(`
164
+ resource "aws_security_group" "a" {
165
+ ingress {
166
+ from_port = 0
167
+ to_port = 0
168
+ protocol = "tcp"
169
+ cidr_blocks = ["0.0.0.0/0"]
170
+ }
171
+ }`);
172
+ expect(d).toEqual([]);
173
+ });
174
+
175
+ test("ingress = var.rules and a dynamic ingress block are not determined", async () => {
176
+ const d = await runInline(`
177
+ resource "aws_security_group" "whole" {
178
+ ingress = var.rules
179
+ }
180
+ resource "aws_security_group" "dyn" {
181
+ dynamic "ingress" {
182
+ for_each = var.ports
183
+ content {
184
+ from_port = ingress.value
185
+ to_port = ingress.value
186
+ protocol = "tcp"
187
+ cidr_blocks = ["0.0.0.0/0"]
188
+ }
189
+ }
190
+ }
191
+ resource "aws_security_group" "dyn_private" {
192
+ dynamic "ingress" {
193
+ for_each = var.ports
194
+ content {
195
+ from_port = ingress.value
196
+ to_port = ingress.value
197
+ protocol = "tcp"
198
+ cidr_blocks = ["10.0.0.0/8"]
199
+ }
200
+ }
201
+ }`);
202
+ const byEntity = new Map(d.map((x) => [x.entity, x]));
203
+ expect([...byEntity.keys()].sort()).toEqual(["inline/aws_security_group.dyn", "inline/aws_security_group.whole"]);
204
+ expect(d.every((x) => x.severity === "info")).toBe(true);
205
+ expect(byEntity.get("inline/aws_security_group.whole")?.message).toContain("`ingress` is `var.rules`");
206
+ expect(byEntity.get("inline/aws_security_group.dyn")?.message).toContain("`for_each = var.ports`");
207
+ });
208
+
209
+ test("a vpc ingress rule with ip_protocol -1 and no ports", async () => {
210
+ const d = await runInline(`
211
+ resource "aws_vpc_security_group_ingress_rule" "all" {
212
+ security_group_id = aws_security_group.x.id
213
+ ip_protocol = "-1"
214
+ cidr_ipv6 = "::/0"
215
+ }`);
216
+ expect(errors(d)).toHaveLength(1);
217
+ });
218
+ });
219
+ });