@intentius/chant-lexicon-aws 0.0.8 → 0.0.9

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/integrity.json +25 -10
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +5743 -896
  4. package/dist/rules/cf-refs.ts +99 -0
  5. package/dist/rules/ext001.ts +30 -21
  6. package/dist/rules/hardcoded-region.ts +1 -0
  7. package/dist/rules/iam-wildcard.ts +1 -0
  8. package/dist/rules/s3-encryption.ts +1 -0
  9. package/dist/rules/waw016.ts +86 -0
  10. package/dist/rules/waw017.ts +53 -0
  11. package/dist/rules/waw018.ts +71 -0
  12. package/dist/rules/waw019.ts +82 -0
  13. package/dist/rules/waw020.ts +64 -0
  14. package/dist/rules/waw021.ts +53 -0
  15. package/dist/rules/waw022.ts +43 -0
  16. package/dist/rules/waw023.ts +47 -0
  17. package/dist/rules/waw024.ts +54 -0
  18. package/dist/rules/waw025.ts +43 -0
  19. package/dist/rules/waw026.ts +46 -0
  20. package/dist/rules/waw027.ts +50 -0
  21. package/dist/rules/waw028.ts +47 -0
  22. package/dist/rules/waw029.ts +62 -0
  23. package/dist/rules/waw030.ts +246 -0
  24. package/dist/skills/chant-aws.md +388 -30
  25. package/dist/types/index.d.ts +1552 -1528
  26. package/package.json +2 -2
  27. package/src/actions/actions.test.ts +75 -0
  28. package/src/actions/dynamodb.ts +36 -0
  29. package/src/actions/ecr.ts +9 -0
  30. package/src/actions/ecs.ts +5 -0
  31. package/src/actions/iam.ts +3 -0
  32. package/src/actions/index.ts +9 -0
  33. package/src/actions/lambda.ts +11 -0
  34. package/src/actions/logs.ts +4 -0
  35. package/src/actions/s3.ts +34 -0
  36. package/src/actions/sns.ts +5 -0
  37. package/src/actions/sqs.ts +15 -0
  38. package/src/codegen/__snapshots__/snapshot.test.ts.snap +2 -2
  39. package/src/codegen/docs-links.test.ts +143 -0
  40. package/src/codegen/docs.ts +247 -132
  41. package/src/codegen/generate-lexicon.ts +8 -0
  42. package/src/codegen/generate-typescript.ts +25 -1
  43. package/src/composites/composites.test.ts +442 -0
  44. package/src/composites/fargate-alb.ts +253 -0
  45. package/src/composites/index.ts +20 -0
  46. package/src/composites/lambda-api.ts +20 -0
  47. package/src/composites/lambda-dynamodb.ts +64 -0
  48. package/src/composites/lambda-eventbridge.ts +36 -0
  49. package/src/composites/lambda-function.ts +76 -0
  50. package/src/composites/lambda-s3.ts +72 -0
  51. package/src/composites/lambda-sns.ts +30 -0
  52. package/src/composites/lambda-sqs.ts +44 -0
  53. package/src/composites/scheduled-lambda.ts +37 -0
  54. package/src/composites/vpc-default.ts +148 -0
  55. package/src/default-tags.test.ts +38 -0
  56. package/src/default-tags.ts +77 -0
  57. package/src/generated/index.d.ts +1552 -1528
  58. package/src/generated/lexicon-aws.json +5743 -896
  59. package/src/import/roundtrip-fixtures.test.ts +1 -1
  60. package/src/index.ts +21 -0
  61. package/src/integration.test.ts +71 -0
  62. package/src/intrinsics.ts +24 -13
  63. package/src/lint/post-synth/cf-refs.ts +99 -0
  64. package/src/lint/post-synth/ext001.test.ts +214 -31
  65. package/src/lint/post-synth/ext001.ts +30 -21
  66. package/src/lint/post-synth/waw013.test.ts +120 -0
  67. package/src/lint/post-synth/waw014.test.ts +121 -0
  68. package/src/lint/post-synth/waw015.test.ts +147 -0
  69. package/src/lint/post-synth/waw016.test.ts +141 -0
  70. package/src/lint/post-synth/waw016.ts +86 -0
  71. package/src/lint/post-synth/waw017.test.ts +130 -0
  72. package/src/lint/post-synth/waw017.ts +53 -0
  73. package/src/lint/post-synth/waw018.test.ts +109 -0
  74. package/src/lint/post-synth/waw018.ts +71 -0
  75. package/src/lint/post-synth/waw019.test.ts +138 -0
  76. package/src/lint/post-synth/waw019.ts +82 -0
  77. package/src/lint/post-synth/waw020.test.ts +125 -0
  78. package/src/lint/post-synth/waw020.ts +64 -0
  79. package/src/lint/post-synth/waw021.test.ts +81 -0
  80. package/src/lint/post-synth/waw021.ts +53 -0
  81. package/src/lint/post-synth/waw022.test.ts +54 -0
  82. package/src/lint/post-synth/waw022.ts +43 -0
  83. package/src/lint/post-synth/waw023.test.ts +53 -0
  84. package/src/lint/post-synth/waw023.ts +47 -0
  85. package/src/lint/post-synth/waw024.test.ts +64 -0
  86. package/src/lint/post-synth/waw024.ts +54 -0
  87. package/src/lint/post-synth/waw025.test.ts +42 -0
  88. package/src/lint/post-synth/waw025.ts +43 -0
  89. package/src/lint/post-synth/waw026.test.ts +54 -0
  90. package/src/lint/post-synth/waw026.ts +46 -0
  91. package/src/lint/post-synth/waw027.test.ts +63 -0
  92. package/src/lint/post-synth/waw027.ts +50 -0
  93. package/src/lint/post-synth/waw028.test.ts +68 -0
  94. package/src/lint/post-synth/waw028.ts +47 -0
  95. package/src/lint/post-synth/waw029.test.ts +179 -0
  96. package/src/lint/post-synth/waw029.ts +62 -0
  97. package/src/lint/post-synth/waw030.test.ts +800 -0
  98. package/src/lint/post-synth/waw030.ts +246 -0
  99. package/src/lint/rules/hardcoded-region.ts +1 -0
  100. package/src/lint/rules/iam-wildcard.ts +1 -0
  101. package/src/lint/rules/s3-encryption.ts +1 -0
  102. package/src/lsp/hover.ts +15 -0
  103. package/src/nested-stack-integration.test.ts +100 -0
  104. package/src/nested-stack.ts +1 -1
  105. package/src/plugin.ts +468 -36
  106. package/src/serializer.test.ts +330 -2
  107. package/src/serializer.ts +62 -1
  108. package/src/spec/fetch.ts +10 -0
  109. package/src/spec/parse.test.ts +141 -0
  110. package/src/spec/parse.ts +40 -0
  111. package/src/taggable.ts +44 -0
  112. package/src/testdata/nested-stacks/app.ts +26 -0
  113. package/src/testdata/nested-stacks/network/outputs.ts +17 -0
  114. package/src/testdata/nested-stacks/network/security.ts +17 -0
  115. package/src/testdata/nested-stacks/network/vpc.ts +54 -0
@@ -0,0 +1,125 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw020, checkIamWildcardAction } from "./waw020";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW020: IAM Wildcard Action", () => {
10
+ test("check metadata", () => {
11
+ expect(waw020.id).toBe("WAW020");
12
+ expect(waw020.description).toContain("wildcard");
13
+ });
14
+
15
+ test("flags IAM::Policy with Action: '*'", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ MyPolicy: {
19
+ Type: "AWS::IAM::Policy",
20
+ Properties: {
21
+ PolicyDocument: {
22
+ Statement: [{ Effect: "Allow", Action: "*", Resource: "*" }],
23
+ },
24
+ },
25
+ },
26
+ },
27
+ });
28
+ const diags = checkIamWildcardAction(ctx);
29
+ expect(diags).toHaveLength(1);
30
+ expect(diags[0].checkId).toBe("WAW020");
31
+ expect(diags[0].severity).toBe("warning");
32
+ });
33
+
34
+ test("flags IAM::Role with wildcard in inline Policies", () => {
35
+ const ctx = makeCtx({
36
+ Resources: {
37
+ MyRole: {
38
+ Type: "AWS::IAM::Role",
39
+ Properties: {
40
+ AssumeRolePolicyDocument: {
41
+ Statement: [{ Effect: "Allow", Principal: { Service: "lambda.amazonaws.com" }, Action: "sts:AssumeRole" }],
42
+ },
43
+ Policies: [
44
+ {
45
+ PolicyName: "admin",
46
+ PolicyDocument: {
47
+ Statement: [{ Effect: "Allow", Action: "*", Resource: "*" }],
48
+ },
49
+ },
50
+ ],
51
+ },
52
+ },
53
+ },
54
+ });
55
+ const diags = checkIamWildcardAction(ctx);
56
+ expect(diags).toHaveLength(1);
57
+ });
58
+
59
+ test("flags wildcard in Action array", () => {
60
+ const ctx = makeCtx({
61
+ Resources: {
62
+ MyPolicy: {
63
+ Type: "AWS::IAM::ManagedPolicy",
64
+ Properties: {
65
+ PolicyDocument: {
66
+ Statement: [{ Effect: "Allow", Action: ["s3:GetObject", "*"], Resource: "*" }],
67
+ },
68
+ },
69
+ },
70
+ },
71
+ });
72
+ const diags = checkIamWildcardAction(ctx);
73
+ expect(diags).toHaveLength(1);
74
+ });
75
+
76
+ test("no diagnostic for specific actions", () => {
77
+ const ctx = makeCtx({
78
+ Resources: {
79
+ MyPolicy: {
80
+ Type: "AWS::IAM::Policy",
81
+ Properties: {
82
+ PolicyDocument: {
83
+ Statement: [{ Effect: "Allow", Action: ["s3:GetObject", "s3:PutObject"], Resource: "arn:aws:s3:::my-bucket/*" }],
84
+ },
85
+ },
86
+ },
87
+ },
88
+ });
89
+ const diags = checkIamWildcardAction(ctx);
90
+ expect(diags).toHaveLength(0);
91
+ });
92
+
93
+ test("no diagnostic for non-IAM resources", () => {
94
+ const ctx = makeCtx({
95
+ Resources: {
96
+ MyBucket: {
97
+ Type: "AWS::S3::Bucket",
98
+ Properties: { BucketName: "test" },
99
+ },
100
+ },
101
+ });
102
+ const diags = checkIamWildcardAction(ctx);
103
+ expect(diags).toHaveLength(0);
104
+ });
105
+
106
+ test("emits one diagnostic per resource even with multiple wildcard statements", () => {
107
+ const ctx = makeCtx({
108
+ Resources: {
109
+ MyPolicy: {
110
+ Type: "AWS::IAM::Policy",
111
+ Properties: {
112
+ PolicyDocument: {
113
+ Statement: [
114
+ { Effect: "Allow", Action: "*", Resource: "*" },
115
+ { Effect: "Allow", Action: "*", Resource: "arn:aws:s3:::*" },
116
+ ],
117
+ },
118
+ },
119
+ },
120
+ },
121
+ });
122
+ const diags = checkIamWildcardAction(ctx);
123
+ expect(diags).toHaveLength(1);
124
+ });
125
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * WAW020: IAM Wildcard Action
3
+ *
4
+ * Flags IAM policies with Action: "*" in any statement.
5
+ * Checks IAM::Policy, IAM::Role, and IAM::ManagedPolicy resource types.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { parseCFTemplate, walkPolicyStatements, isIntrinsic } from "./cf-refs";
10
+
11
+ const IAM_TYPES = new Set([
12
+ "AWS::IAM::Policy",
13
+ "AWS::IAM::Role",
14
+ "AWS::IAM::ManagedPolicy",
15
+ ]);
16
+
17
+ function hasWildcardAction(statement: Record<string, unknown>): boolean {
18
+ const action = statement.Action;
19
+ if (action === "*") return true;
20
+ if (Array.isArray(action)) {
21
+ return action.some((a) => a === "*");
22
+ }
23
+ return false;
24
+ }
25
+
26
+ export function checkIamWildcardAction(ctx: PostSynthContext): PostSynthDiagnostic[] {
27
+ const diagnostics: PostSynthDiagnostic[] = [];
28
+
29
+ for (const [_lexicon, output] of ctx.outputs) {
30
+ const template = parseCFTemplate(output);
31
+ if (!template?.Resources) continue;
32
+
33
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
34
+ if (!IAM_TYPES.has(resource.Type)) continue;
35
+
36
+ const statements = walkPolicyStatements(resource);
37
+ for (const stmt of statements) {
38
+ if (isIntrinsic(stmt.Action)) continue;
39
+
40
+ if (hasWildcardAction(stmt)) {
41
+ diagnostics.push({
42
+ checkId: "WAW020",
43
+ severity: "warning",
44
+ message: `IAM resource "${logicalId}" has a policy statement with Action: "*" — use specific actions following least privilege`,
45
+ entity: logicalId,
46
+ lexicon: "aws",
47
+ });
48
+ break; // One diagnostic per resource
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ return diagnostics;
55
+ }
56
+
57
+ export const waw020: PostSynthCheck = {
58
+ id: "WAW020",
59
+ description: "IAM policy uses wildcard Action — use specific actions following least privilege",
60
+
61
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
62
+ return checkIamWildcardAction(ctx);
63
+ },
64
+ };
@@ -0,0 +1,81 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw021, checkRdsEncryption } from "./waw021";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW021: RDS Storage Not Encrypted", () => {
10
+ test("check metadata", () => {
11
+ expect(waw021.id).toBe("WAW021");
12
+ expect(waw021.description).toContain("encrypted");
13
+ });
14
+
15
+ test("flags DBInstance without StorageEncrypted", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ MyDB: {
19
+ Type: "AWS::RDS::DBInstance",
20
+ Properties: { DBInstanceClass: "db.t3.micro", Engine: "mysql" },
21
+ },
22
+ },
23
+ });
24
+ const diags = checkRdsEncryption(ctx);
25
+ expect(diags).toHaveLength(1);
26
+ expect(diags[0].checkId).toBe("WAW021");
27
+ expect(diags[0].severity).toBe("error");
28
+ });
29
+
30
+ test("flags DBCluster without StorageEncrypted", () => {
31
+ const ctx = makeCtx({
32
+ Resources: {
33
+ MyCluster: {
34
+ Type: "AWS::RDS::DBCluster",
35
+ Properties: { Engine: "aurora-mysql" },
36
+ },
37
+ },
38
+ });
39
+ const diags = checkRdsEncryption(ctx);
40
+ expect(diags).toHaveLength(1);
41
+ });
42
+
43
+ test("no diagnostic when StorageEncrypted: true", () => {
44
+ const ctx = makeCtx({
45
+ Resources: {
46
+ MyDB: {
47
+ Type: "AWS::RDS::DBInstance",
48
+ Properties: { StorageEncrypted: true, DBInstanceClass: "db.t3.micro" },
49
+ },
50
+ },
51
+ });
52
+ const diags = checkRdsEncryption(ctx);
53
+ expect(diags).toHaveLength(0);
54
+ });
55
+
56
+ test("flags StorageEncrypted: false", () => {
57
+ const ctx = makeCtx({
58
+ Resources: {
59
+ MyDB: {
60
+ Type: "AWS::RDS::DBInstance",
61
+ Properties: { StorageEncrypted: false, DBInstanceClass: "db.t3.micro" },
62
+ },
63
+ },
64
+ });
65
+ const diags = checkRdsEncryption(ctx);
66
+ expect(diags).toHaveLength(1);
67
+ });
68
+
69
+ test("skips intrinsic value for StorageEncrypted", () => {
70
+ const ctx = makeCtx({
71
+ Resources: {
72
+ MyDB: {
73
+ Type: "AWS::RDS::DBInstance",
74
+ Properties: { StorageEncrypted: { Ref: "EncryptParam" } },
75
+ },
76
+ },
77
+ });
78
+ const diags = checkRdsEncryption(ctx);
79
+ expect(diags).toHaveLength(0);
80
+ });
81
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * WAW021: RDS Storage Not Encrypted
3
+ *
4
+ * Flags RDS instances and clusters without StorageEncrypted: true.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { parseCFTemplate, isIntrinsic } from "./cf-refs";
9
+
10
+ const RDS_TYPES = new Set([
11
+ "AWS::RDS::DBInstance",
12
+ "AWS::RDS::DBCluster",
13
+ ]);
14
+
15
+ export function checkRdsEncryption(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
+ const diagnostics: PostSynthDiagnostic[] = [];
17
+
18
+ for (const [_lexicon, output] of ctx.outputs) {
19
+ const template = parseCFTemplate(output);
20
+ if (!template?.Resources) continue;
21
+
22
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
23
+ if (!RDS_TYPES.has(resource.Type)) continue;
24
+
25
+ const props = resource.Properties ?? {};
26
+ const encrypted = props.StorageEncrypted;
27
+
28
+ // Skip if it's an intrinsic (can't statically verify)
29
+ if (isIntrinsic(encrypted)) continue;
30
+
31
+ if (encrypted !== true) {
32
+ diagnostics.push({
33
+ checkId: "WAW021",
34
+ severity: "error",
35
+ message: `RDS resource "${logicalId}" (${resource.Type}) does not have StorageEncrypted: true — enable encryption at rest`,
36
+ entity: logicalId,
37
+ lexicon: "aws",
38
+ });
39
+ }
40
+ }
41
+ }
42
+
43
+ return diagnostics;
44
+ }
45
+
46
+ export const waw021: PostSynthCheck = {
47
+ id: "WAW021",
48
+ description: "RDS instance or cluster storage is not encrypted — enable encryption at rest",
49
+
50
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
51
+ return checkRdsEncryption(ctx);
52
+ },
53
+ };
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw022, checkLambdaVpc } from "./waw022";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW022: Lambda Not in VPC", () => {
10
+ test("check metadata", () => {
11
+ expect(waw022.id).toBe("WAW022");
12
+ expect(waw022.description).toContain("VPC");
13
+ });
14
+
15
+ test("flags Lambda without VpcConfig", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ MyFunc: {
19
+ Type: "AWS::Lambda::Function",
20
+ Properties: { FunctionName: "test", Runtime: "nodejs20.x", Handler: "index.handler" },
21
+ },
22
+ },
23
+ });
24
+ const diags = checkLambdaVpc(ctx);
25
+ expect(diags).toHaveLength(1);
26
+ expect(diags[0].checkId).toBe("WAW022");
27
+ expect(diags[0].severity).toBe("warning");
28
+ });
29
+
30
+ test("no diagnostic when VpcConfig is present", () => {
31
+ const ctx = makeCtx({
32
+ Resources: {
33
+ MyFunc: {
34
+ Type: "AWS::Lambda::Function",
35
+ Properties: {
36
+ VpcConfig: { SubnetIds: ["subnet-123"], SecurityGroupIds: ["sg-123"] },
37
+ },
38
+ },
39
+ },
40
+ });
41
+ const diags = checkLambdaVpc(ctx);
42
+ expect(diags).toHaveLength(0);
43
+ });
44
+
45
+ test("no diagnostic for non-Lambda resources", () => {
46
+ const ctx = makeCtx({
47
+ Resources: {
48
+ MyBucket: { Type: "AWS::S3::Bucket", Properties: {} },
49
+ },
50
+ });
51
+ const diags = checkLambdaVpc(ctx);
52
+ expect(diags).toHaveLength(0);
53
+ });
54
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * WAW022: Lambda Not in VPC
3
+ *
4
+ * Flags Lambda functions without VpcConfig.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { parseCFTemplate } from "./cf-refs";
9
+
10
+ export function checkLambdaVpc(ctx: PostSynthContext): PostSynthDiagnostic[] {
11
+ const diagnostics: PostSynthDiagnostic[] = [];
12
+
13
+ for (const [_lexicon, output] of ctx.outputs) {
14
+ const template = parseCFTemplate(output);
15
+ if (!template?.Resources) continue;
16
+
17
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
18
+ if (resource.Type !== "AWS::Lambda::Function") continue;
19
+
20
+ const props = resource.Properties ?? {};
21
+ if (!("VpcConfig" in props)) {
22
+ diagnostics.push({
23
+ checkId: "WAW022",
24
+ severity: "warning",
25
+ message: `Lambda function "${logicalId}" is not configured with a VPC — consider adding VpcConfig for network isolation`,
26
+ entity: logicalId,
27
+ lexicon: "aws",
28
+ });
29
+ }
30
+ }
31
+ }
32
+
33
+ return diagnostics;
34
+ }
35
+
36
+ export const waw022: PostSynthCheck = {
37
+ id: "WAW022",
38
+ description: "Lambda function is not configured with a VPC — consider adding VpcConfig for network isolation",
39
+
40
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
41
+ return checkLambdaVpc(ctx);
42
+ },
43
+ };
@@ -0,0 +1,53 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw023, checkCloudFrontWaf } from "./waw023";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW023: CloudFront Without WAF", () => {
10
+ test("check metadata", () => {
11
+ expect(waw023.id).toBe("WAW023");
12
+ expect(waw023.description).toContain("WAF");
13
+ });
14
+
15
+ test("flags distribution without WebACLId", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ MyCF: {
19
+ Type: "AWS::CloudFront::Distribution",
20
+ Properties: {
21
+ DistributionConfig: {
22
+ Origins: [{ Id: "origin1", DomainName: "example.com" }],
23
+ DefaultCacheBehavior: { TargetOriginId: "origin1", ViewerProtocolPolicy: "redirect-to-https" },
24
+ Enabled: true,
25
+ },
26
+ },
27
+ },
28
+ },
29
+ });
30
+ const diags = checkCloudFrontWaf(ctx);
31
+ expect(diags).toHaveLength(1);
32
+ expect(diags[0].checkId).toBe("WAW023");
33
+ expect(diags[0].severity).toBe("warning");
34
+ });
35
+
36
+ test("no diagnostic when WebACLId is present", () => {
37
+ const ctx = makeCtx({
38
+ Resources: {
39
+ MyCF: {
40
+ Type: "AWS::CloudFront::Distribution",
41
+ Properties: {
42
+ DistributionConfig: {
43
+ WebACLId: "arn:aws:wafv2:us-east-1:123456789012:global/webacl/my-acl/abc",
44
+ Enabled: true,
45
+ },
46
+ },
47
+ },
48
+ },
49
+ });
50
+ const diags = checkCloudFrontWaf(ctx);
51
+ expect(diags).toHaveLength(0);
52
+ });
53
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * WAW023: CloudFront Without WAF
3
+ *
4
+ * Flags CloudFront distributions without WebACLId.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { parseCFTemplate } from "./cf-refs";
9
+
10
+ export function checkCloudFrontWaf(ctx: PostSynthContext): PostSynthDiagnostic[] {
11
+ const diagnostics: PostSynthDiagnostic[] = [];
12
+
13
+ for (const [_lexicon, output] of ctx.outputs) {
14
+ const template = parseCFTemplate(output);
15
+ if (!template?.Resources) continue;
16
+
17
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
18
+ if (resource.Type !== "AWS::CloudFront::Distribution") continue;
19
+
20
+ const props = resource.Properties ?? {};
21
+ const distConfig = props.DistributionConfig;
22
+ if (typeof distConfig !== "object" || distConfig === null) continue;
23
+
24
+ const config = distConfig as Record<string, unknown>;
25
+ if (!("WebACLId" in config)) {
26
+ diagnostics.push({
27
+ checkId: "WAW023",
28
+ severity: "warning",
29
+ message: `CloudFront distribution "${logicalId}" has no WebACLId — consider attaching a WAF web ACL for protection`,
30
+ entity: logicalId,
31
+ lexicon: "aws",
32
+ });
33
+ }
34
+ }
35
+ }
36
+
37
+ return diagnostics;
38
+ }
39
+
40
+ export const waw023: PostSynthCheck = {
41
+ id: "WAW023",
42
+ description: "CloudFront distribution has no WAF web ACL — consider attaching one for protection",
43
+
44
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
45
+ return checkCloudFrontWaf(ctx);
46
+ },
47
+ };
@@ -0,0 +1,64 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw024, checkAlbAccessLogs } from "./waw024";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW024: ALB Without Access Logging", () => {
10
+ test("check metadata", () => {
11
+ expect(waw024.id).toBe("WAW024");
12
+ expect(waw024.description).toContain("access logging");
13
+ });
14
+
15
+ test("flags ALB without access logging", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ MyALB: {
19
+ Type: "AWS::ElasticLoadBalancingV2::LoadBalancer",
20
+ Properties: { Type: "application" },
21
+ },
22
+ },
23
+ });
24
+ const diags = checkAlbAccessLogs(ctx);
25
+ expect(diags).toHaveLength(1);
26
+ expect(diags[0].checkId).toBe("WAW024");
27
+ expect(diags[0].severity).toBe("warning");
28
+ });
29
+
30
+ test("no diagnostic when access logging is enabled", () => {
31
+ const ctx = makeCtx({
32
+ Resources: {
33
+ MyALB: {
34
+ Type: "AWS::ElasticLoadBalancingV2::LoadBalancer",
35
+ Properties: {
36
+ LoadBalancerAttributes: [
37
+ { Key: "access_logs.s3.enabled", Value: "true" },
38
+ { Key: "access_logs.s3.bucket", Value: "my-logs-bucket" },
39
+ ],
40
+ },
41
+ },
42
+ },
43
+ });
44
+ const diags = checkAlbAccessLogs(ctx);
45
+ expect(diags).toHaveLength(0);
46
+ });
47
+
48
+ test("flags when access_logs.s3.enabled is false", () => {
49
+ const ctx = makeCtx({
50
+ Resources: {
51
+ MyALB: {
52
+ Type: "AWS::ElasticLoadBalancingV2::LoadBalancer",
53
+ Properties: {
54
+ LoadBalancerAttributes: [
55
+ { Key: "access_logs.s3.enabled", Value: "false" },
56
+ ],
57
+ },
58
+ },
59
+ },
60
+ });
61
+ const diags = checkAlbAccessLogs(ctx);
62
+ expect(diags).toHaveLength(1);
63
+ });
64
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * WAW024: ALB Without Access Logging
3
+ *
4
+ * Flags Application Load Balancers without access logging enabled.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { parseCFTemplate } from "./cf-refs";
9
+
10
+ export function checkAlbAccessLogs(ctx: PostSynthContext): PostSynthDiagnostic[] {
11
+ const diagnostics: PostSynthDiagnostic[] = [];
12
+
13
+ for (const [_lexicon, output] of ctx.outputs) {
14
+ const template = parseCFTemplate(output);
15
+ if (!template?.Resources) continue;
16
+
17
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
18
+ if (resource.Type !== "AWS::ElasticLoadBalancingV2::LoadBalancer") continue;
19
+
20
+ const props = resource.Properties ?? {};
21
+ const attrs = props.LoadBalancerAttributes;
22
+
23
+ let hasAccessLogs = false;
24
+ if (Array.isArray(attrs)) {
25
+ hasAccessLogs = attrs.some((attr) => {
26
+ if (typeof attr !== "object" || attr === null) return false;
27
+ const a = attr as Record<string, unknown>;
28
+ return a.Key === "access_logs.s3.enabled" && (a.Value === "true" || a.Value === true);
29
+ });
30
+ }
31
+
32
+ if (!hasAccessLogs) {
33
+ diagnostics.push({
34
+ checkId: "WAW024",
35
+ severity: "warning",
36
+ message: `Load balancer "${logicalId}" does not have access logging enabled — enable access_logs.s3.enabled for audit trails`,
37
+ entity: logicalId,
38
+ lexicon: "aws",
39
+ });
40
+ }
41
+ }
42
+ }
43
+
44
+ return diagnostics;
45
+ }
46
+
47
+ export const waw024: PostSynthCheck = {
48
+ id: "WAW024",
49
+ description: "Application Load Balancer does not have access logging enabled",
50
+
51
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
52
+ return checkAlbAccessLogs(ctx);
53
+ },
54
+ };
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw025, checkSnsEncryption } from "./waw025";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW025: SNS Topic Not Encrypted", () => {
10
+ test("check metadata", () => {
11
+ expect(waw025.id).toBe("WAW025");
12
+ expect(waw025.description).toContain("encrypted");
13
+ });
14
+
15
+ test("flags topic without KmsMasterKeyId", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ MyTopic: {
19
+ Type: "AWS::SNS::Topic",
20
+ Properties: { TopicName: "my-topic" },
21
+ },
22
+ },
23
+ });
24
+ const diags = checkSnsEncryption(ctx);
25
+ expect(diags).toHaveLength(1);
26
+ expect(diags[0].checkId).toBe("WAW025");
27
+ expect(diags[0].severity).toBe("warning");
28
+ });
29
+
30
+ test("no diagnostic when KmsMasterKeyId is set", () => {
31
+ const ctx = makeCtx({
32
+ Resources: {
33
+ MyTopic: {
34
+ Type: "AWS::SNS::Topic",
35
+ Properties: { KmsMasterKeyId: "alias/my-key" },
36
+ },
37
+ },
38
+ });
39
+ const diags = checkSnsEncryption(ctx);
40
+ expect(diags).toHaveLength(0);
41
+ });
42
+ });