@intentius/chant-lexicon-aws 0.49.0 → 0.51.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/generate-lexicon.d.ts +5 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/components/host-delivery.d.ts +4 -3
- package/dist/components/host-delivery.d.ts.map +1 -1
- package/dist/disruption.d.ts +26 -0
- package/dist/disruption.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +9 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw016.d.ts +10 -2
- package/dist/lint/post-synth/waw016.d.ts.map +1 -1
- package/dist/lint/post-synth/waw064.d.ts +17 -0
- package/dist/lint/post-synth/waw064.d.ts.map +1 -0
- package/dist/lint/post-synth/waw065.d.ts +23 -0
- package/dist/lint/post-synth/waw065.d.ts.map +1 -0
- package/dist/lint/post-synth/waw066.d.ts +25 -0
- package/dist/lint/post-synth/waw066.d.ts.map +1 -0
- package/dist/lint/post-synth/waw067.d.ts +24 -0
- package/dist/lint/post-synth/waw067.d.ts.map +1 -0
- package/dist/lint/post-synth/waw068.d.ts +20 -0
- package/dist/lint/post-synth/waw068.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +34 -0
- package/dist/okf/index.md +5 -0
- package/dist/okf/rules/WAW064.md +18 -0
- package/dist/okf/rules/WAW065.md +25 -0
- package/dist/okf/rules/WAW066.md +22 -0
- package/dist/okf/rules/WAW067.md +20 -0
- package/dist/okf/rules/WAW068.md +19 -0
- package/dist/okf/types/EC2Route.md +2 -0
- package/dist/okf/types/InternetGateway.md +4 -0
- package/dist/okf/types/Map.md +4 -0
- package/dist/okf/types/NatGateway.md +4 -0
- package/dist/okf/types/RouteTable.md +4 -0
- package/dist/okf/types/Subnet.md +2 -0
- package/dist/okf/types/SubnetRouteTableAssociation.md +5 -0
- package/dist/okf/types/Table.md +3 -0
- package/dist/okf/types/TransitGateway.md +6 -0
- package/dist/okf/types/TransitGatewayAttachment.md +4 -0
- package/dist/okf/types/TransitGatewayPeeringAttachment.md +4 -0
- package/dist/okf/types/TransitGatewayRoute.md +5 -0
- package/dist/okf/types/TransitGatewayRouteTable.md +4 -0
- package/dist/okf/types/TransitGatewayRouteTableAssociation.md +4 -0
- package/dist/okf/types/TransitGatewayRouteTablePropagation.md +4 -0
- package/dist/okf/types/TransitGatewayVpcAttachment.md +4 -0
- package/dist/okf/types/Type.md +5 -0
- package/dist/okf/types/VPNConnection.md +4 -0
- package/dist/okf/types/VPNGateway.md +4 -0
- package/dist/op/builders.d.ts +47 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/waw016.ts +37 -16
- package/dist/rules/waw064.ts +52 -0
- package/dist/rules/waw065.ts +110 -0
- package/dist/rules/waw066.ts +155 -0
- package/dist/rules/waw067.ts +114 -0
- package/dist/rules/waw068.ts +78 -0
- package/dist/spec/parse.d.ts +7 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen/fallback.ts +1 -0
- package/src/codegen/generate-lexicon.ts +6 -0
- package/src/codegen/sam.ts +9 -0
- package/src/components/host-delivery.ts +4 -3
- package/src/disruption.test.ts +198 -0
- package/src/disruption.ts +154 -0
- package/src/generated/lexicon-aws.json +34 -0
- package/src/index.ts +8 -0
- package/src/lint/audit-catalog.ts +10 -0
- package/src/lint/post-synth/index.ts +10 -0
- package/src/lint/post-synth/waw016.test.ts +61 -4
- package/src/lint/post-synth/waw016.ts +37 -16
- package/src/lint/post-synth/waw064.test.ts +79 -0
- package/src/lint/post-synth/waw064.ts +52 -0
- package/src/lint/post-synth/waw065.test.ts +90 -0
- package/src/lint/post-synth/waw065.ts +110 -0
- package/src/lint/post-synth/waw066.test.ts +108 -0
- package/src/lint/post-synth/waw066.ts +155 -0
- package/src/lint/post-synth/waw067.test.ts +79 -0
- package/src/lint/post-synth/waw067.ts +114 -0
- package/src/lint/post-synth/waw068.test.ts +74 -0
- package/src/lint/post-synth/waw068.ts +78 -0
- package/src/lsp/hover.ts +14 -2
- package/src/op/builders.test.ts +61 -0
- package/src/op/builders.ts +72 -0
- package/src/plugin.ts +16 -1
- package/src/spec/parse.test.ts +74 -0
- package/src/spec/parse.ts +26 -6
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw065, checkTgwRouteTableWiring } from "./waw065";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("WAW065: Transit Gateway Route Table Wiring Is Incomplete", () => {
|
|
10
|
+
test("check metadata", () => {
|
|
11
|
+
expect(waw065.id).toBe("WAW065");
|
|
12
|
+
expect(waw065.description).toContain("propagation");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("route table with an association but no propagation → warning", () => {
|
|
16
|
+
const ctx = makeCtx({
|
|
17
|
+
Resources: {
|
|
18
|
+
Tgw: { Type: "AWS::EC2::TransitGateway", Properties: {} },
|
|
19
|
+
SpokeRouteTable: { Type: "AWS::EC2::TransitGatewayRouteTable", Properties: { TransitGatewayId: { Ref: "Tgw" } } },
|
|
20
|
+
SpokeAttachment: { Type: "AWS::EC2::TransitGatewayVpcAttachment", Properties: { TransitGatewayId: { Ref: "Tgw" } } },
|
|
21
|
+
SpokeAssociation: {
|
|
22
|
+
Type: "AWS::EC2::TransitGatewayRouteTableAssociation",
|
|
23
|
+
Properties: { TransitGatewayAttachmentId: { Ref: "SpokeAttachment" }, TransitGatewayRouteTableId: { Ref: "SpokeRouteTable" } },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const diags = checkTgwRouteTableWiring(ctx);
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
expect(diags[0].checkId).toBe("WAW065");
|
|
30
|
+
expect(diags[0].entity).toBe("SpokeRouteTable");
|
|
31
|
+
expect(diags[0].message).toContain("no route propagations");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("attachment referenced by neither an association nor a propagation → warning", () => {
|
|
35
|
+
const ctx = makeCtx({
|
|
36
|
+
Resources: {
|
|
37
|
+
Tgw: { Type: "AWS::EC2::TransitGateway", Properties: {} },
|
|
38
|
+
SpokeRouteTable: { Type: "AWS::EC2::TransitGatewayRouteTable", Properties: { TransitGatewayId: { Ref: "Tgw" } } },
|
|
39
|
+
OrphanAttachment: { Type: "AWS::EC2::TransitGatewayVpcAttachment", Properties: { TransitGatewayId: { Ref: "Tgw" } } },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const diags = checkTgwRouteTableWiring(ctx);
|
|
43
|
+
expect(diags).toHaveLength(1);
|
|
44
|
+
expect(diags[0].entity).toBe("OrphanAttachment");
|
|
45
|
+
expect(diags[0].message).toContain("no route table wiring");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("both patterns fire independently in the same template", () => {
|
|
49
|
+
const ctx = makeCtx({
|
|
50
|
+
Resources: {
|
|
51
|
+
Tgw: { Type: "AWS::EC2::TransitGateway", Properties: {} },
|
|
52
|
+
SpokeRouteTable: { Type: "AWS::EC2::TransitGatewayRouteTable", Properties: { TransitGatewayId: { Ref: "Tgw" } } },
|
|
53
|
+
SpokeAttachment: { Type: "AWS::EC2::TransitGatewayVpcAttachment", Properties: { TransitGatewayId: { Ref: "Tgw" } } },
|
|
54
|
+
SpokeAssociation: {
|
|
55
|
+
Type: "AWS::EC2::TransitGatewayRouteTableAssociation",
|
|
56
|
+
Properties: { TransitGatewayAttachmentId: { Ref: "SpokeAttachment" }, TransitGatewayRouteTableId: { Ref: "SpokeRouteTable" } },
|
|
57
|
+
},
|
|
58
|
+
OrphanAttachment: { Type: "AWS::EC2::TransitGatewayVpnAttachment", Properties: { TransitGatewayId: { Ref: "Tgw" } } },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const diags = checkTgwRouteTableWiring(ctx);
|
|
62
|
+
expect(diags).toHaveLength(2);
|
|
63
|
+
const entities = diags.map((d) => d.entity).sort();
|
|
64
|
+
expect(entities).toEqual(["OrphanAttachment", "SpokeRouteTable"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("route table with both an association and a propagation, attachment referenced by both → no diagnostic", () => {
|
|
68
|
+
const ctx = makeCtx({
|
|
69
|
+
Resources: {
|
|
70
|
+
Tgw: { Type: "AWS::EC2::TransitGateway", Properties: {} },
|
|
71
|
+
SpokeRouteTable: { Type: "AWS::EC2::TransitGatewayRouteTable", Properties: { TransitGatewayId: { Ref: "Tgw" } } },
|
|
72
|
+
SpokeAttachment: { Type: "AWS::EC2::TransitGatewayVpcAttachment", Properties: { TransitGatewayId: { Ref: "Tgw" } } },
|
|
73
|
+
SpokeAssociation: {
|
|
74
|
+
Type: "AWS::EC2::TransitGatewayRouteTableAssociation",
|
|
75
|
+
Properties: { TransitGatewayAttachmentId: { Ref: "SpokeAttachment" }, TransitGatewayRouteTableId: { Ref: "SpokeRouteTable" } },
|
|
76
|
+
},
|
|
77
|
+
SpokePropagation: {
|
|
78
|
+
Type: "AWS::EC2::TransitGatewayRouteTablePropagation",
|
|
79
|
+
Properties: { TransitGatewayAttachmentId: { Ref: "SpokeAttachment" }, TransitGatewayRouteTableId: { Ref: "SpokeRouteTable" } },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
expect(checkTgwRouteTableWiring(ctx)).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("no transit gateway resources at all → no diagnostic", () => {
|
|
87
|
+
const ctx = makeCtx({ Resources: { Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } } } });
|
|
88
|
+
expect(checkTgwRouteTableWiring(ctx)).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW065: Transit Gateway Route Table Wiring Is Incomplete
|
|
3
|
+
*
|
|
4
|
+
* A Transit Gateway route table only does anything once two independent
|
|
5
|
+
* pieces are both wired up: an `AWS::EC2::TransitGatewayRouteTableAssociation`
|
|
6
|
+
* (which attachment's traffic is evaluated against this table) and an
|
|
7
|
+
* `AWS::EC2::TransitGatewayRouteTablePropagation` (which attachments' routes
|
|
8
|
+
* populate it). CloudFormation is happy to deploy a route table with one but
|
|
9
|
+
* not the other — the stack succeeds, and the gap only shows up as traffic
|
|
10
|
+
* that can't find a route. Two patterns, both "declared but half-wired":
|
|
11
|
+
*
|
|
12
|
+
* - a route table has one or more associations but zero propagations — the
|
|
13
|
+
* associated attachment's traffic is evaluated against this table, but
|
|
14
|
+
* nothing ever populates it with routes;
|
|
15
|
+
* - a declared attachment (VPC/VPN/peering, or the generic
|
|
16
|
+
* TransitGatewayAttachment) is referenced by neither an association nor
|
|
17
|
+
* a propagation anywhere in the template — it hangs off the transit
|
|
18
|
+
* gateway with no route table wiring at all.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
22
|
+
import { parseCFTemplate, findResourceRefs } from "./cf-refs";
|
|
23
|
+
|
|
24
|
+
const ATTACHMENT_TYPES = new Set([
|
|
25
|
+
"AWS::EC2::TransitGatewayAttachment",
|
|
26
|
+
"AWS::EC2::TransitGatewayVpcAttachment",
|
|
27
|
+
"AWS::EC2::TransitGatewayVpnAttachment",
|
|
28
|
+
"AWS::EC2::TransitGatewayPeeringAttachment",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export function checkTgwRouteTableWiring(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
32
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
33
|
+
|
|
34
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
35
|
+
const template = parseCFTemplate(output);
|
|
36
|
+
if (!template?.Resources) continue;
|
|
37
|
+
const resources = template.Resources;
|
|
38
|
+
|
|
39
|
+
const routeTableIds = new Set<string>();
|
|
40
|
+
const attachmentIds = new Set<string>();
|
|
41
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
42
|
+
if (resource.Type === "AWS::EC2::TransitGatewayRouteTable") routeTableIds.add(logicalId);
|
|
43
|
+
if (ATTACHMENT_TYPES.has(resource.Type)) attachmentIds.add(logicalId);
|
|
44
|
+
}
|
|
45
|
+
if (routeTableIds.size === 0 && attachmentIds.size === 0) continue;
|
|
46
|
+
|
|
47
|
+
const associationsByTable = new Map<string, Set<string>>();
|
|
48
|
+
const propagationsByTable = new Map<string, Set<string>>();
|
|
49
|
+
const referencedAttachments = new Set<string>();
|
|
50
|
+
|
|
51
|
+
for (const resource of Object.values(resources)) {
|
|
52
|
+
let bucket: Map<string, Set<string>> | null = null;
|
|
53
|
+
if (resource.Type === "AWS::EC2::TransitGatewayRouteTableAssociation") bucket = associationsByTable;
|
|
54
|
+
if (resource.Type === "AWS::EC2::TransitGatewayRouteTablePropagation") bucket = propagationsByTable;
|
|
55
|
+
if (!bucket) continue;
|
|
56
|
+
|
|
57
|
+
const props = resource.Properties ?? {};
|
|
58
|
+
const attachmentRefs = findResourceRefs(props.TransitGatewayAttachmentId);
|
|
59
|
+
for (const attId of attachmentRefs) referencedAttachments.add(attId);
|
|
60
|
+
|
|
61
|
+
for (const rtId of findResourceRefs(props.TransitGatewayRouteTableId)) {
|
|
62
|
+
let attSet = bucket.get(rtId);
|
|
63
|
+
if (!attSet) {
|
|
64
|
+
attSet = new Set();
|
|
65
|
+
bucket.set(rtId, attSet);
|
|
66
|
+
}
|
|
67
|
+
for (const attId of attachmentRefs) attSet.add(attId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Pattern A: associated but never propagated.
|
|
72
|
+
for (const rtId of routeTableIds) {
|
|
73
|
+
const associations = associationsByTable.get(rtId);
|
|
74
|
+
const propagations = propagationsByTable.get(rtId);
|
|
75
|
+
if (associations && associations.size > 0 && (!propagations || propagations.size === 0)) {
|
|
76
|
+
diagnostics.push({
|
|
77
|
+
checkId: "WAW065",
|
|
78
|
+
severity: "warning",
|
|
79
|
+
message: `TransitGatewayRouteTable "${rtId}" has ${associations.size} association(s) but no route propagations — the associated attachment's routes never populate this table, likely incomplete wiring`,
|
|
80
|
+
entity: rtId,
|
|
81
|
+
lexicon: "aws",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Pattern B: a declared attachment wired into no route table at all.
|
|
87
|
+
for (const attId of attachmentIds) {
|
|
88
|
+
if (!referencedAttachments.has(attId)) {
|
|
89
|
+
diagnostics.push({
|
|
90
|
+
checkId: "WAW065",
|
|
91
|
+
severity: "warning",
|
|
92
|
+
message: `Transit Gateway attachment "${attId}" is referenced by no TransitGatewayRouteTableAssociation or TransitGatewayRouteTablePropagation — it has no route table wiring, so nothing can route to or from it`,
|
|
93
|
+
entity: attId,
|
|
94
|
+
lexicon: "aws",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return diagnostics;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const waw065: PostSynthCheck = {
|
|
104
|
+
id: "WAW065",
|
|
105
|
+
description: "Transit Gateway route table has associations but no propagations, or an attachment wired into no route table",
|
|
106
|
+
|
|
107
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
108
|
+
return checkTgwRouteTableWiring(ctx);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw066, checkPrivateSubnetDefaultRoute } from "./waw066";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("WAW066: Private Subnet Route Table Has No Working Default Route", () => {
|
|
10
|
+
test("check metadata", () => {
|
|
11
|
+
expect(waw066.id).toBe("WAW066");
|
|
12
|
+
expect(waw066.description).toContain("default route");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("subnet's route table has no default route at all → warning", () => {
|
|
16
|
+
const ctx = makeCtx({
|
|
17
|
+
Resources: {
|
|
18
|
+
PrivateSubnet: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24" } },
|
|
19
|
+
PrivateRouteTable: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
|
|
20
|
+
PrivateAssoc: {
|
|
21
|
+
Type: "AWS::EC2::SubnetRouteTableAssociation",
|
|
22
|
+
Properties: { SubnetId: { Ref: "PrivateSubnet" }, RouteTableId: { Ref: "PrivateRouteTable" } },
|
|
23
|
+
},
|
|
24
|
+
Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const diags = checkPrivateSubnetDefaultRoute(ctx);
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
expect(diags[0].checkId).toBe("WAW066");
|
|
30
|
+
expect(diags[0].severity).toBe("warning");
|
|
31
|
+
expect(diags[0].entity).toBe("PrivateSubnet");
|
|
32
|
+
expect(diags[0].message).toContain("no default route");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("default route targets a NAT gateway that does not exist in the template → error", () => {
|
|
36
|
+
const ctx = makeCtx({
|
|
37
|
+
Resources: {
|
|
38
|
+
PrivateSubnet: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24" } },
|
|
39
|
+
PrivateRouteTable: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
|
|
40
|
+
PrivateAssoc: {
|
|
41
|
+
Type: "AWS::EC2::SubnetRouteTableAssociation",
|
|
42
|
+
Properties: { SubnetId: { Ref: "PrivateSubnet" }, RouteTableId: { Ref: "PrivateRouteTable" } },
|
|
43
|
+
},
|
|
44
|
+
DefaultRoute: {
|
|
45
|
+
Type: "AWS::EC2::Route",
|
|
46
|
+
Properties: { RouteTableId: { Ref: "PrivateRouteTable" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "RemovedNatGateway" } },
|
|
47
|
+
},
|
|
48
|
+
Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const diags = checkPrivateSubnetDefaultRoute(ctx);
|
|
52
|
+
expect(diags).toHaveLength(1);
|
|
53
|
+
expect(diags[0].severity).toBe("error");
|
|
54
|
+
expect(diags[0].entity).toBe("PrivateSubnet");
|
|
55
|
+
expect(diags[0].message).toContain("does not exist in this template");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("default route to a NAT gateway that exists in the template → no diagnostic", () => {
|
|
59
|
+
const ctx = makeCtx({
|
|
60
|
+
Resources: {
|
|
61
|
+
PrivateSubnet: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24" } },
|
|
62
|
+
PrivateRouteTable: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
|
|
63
|
+
PrivateAssoc: {
|
|
64
|
+
Type: "AWS::EC2::SubnetRouteTableAssociation",
|
|
65
|
+
Properties: { SubnetId: { Ref: "PrivateSubnet" }, RouteTableId: { Ref: "PrivateRouteTable" } },
|
|
66
|
+
},
|
|
67
|
+
DefaultRoute: {
|
|
68
|
+
Type: "AWS::EC2::Route",
|
|
69
|
+
Properties: { RouteTableId: { Ref: "PrivateRouteTable" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGateway" } },
|
|
70
|
+
},
|
|
71
|
+
NatGateway: { Type: "AWS::EC2::NatGateway", Properties: { SubnetId: { Ref: "PublicSubnet" } } },
|
|
72
|
+
PublicSubnet: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.0.0/24" } },
|
|
73
|
+
Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
expect(checkPrivateSubnetDefaultRoute(ctx)).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("public subnet with a default route to an existing Internet Gateway → no diagnostic (out of scope)", () => {
|
|
80
|
+
const ctx = makeCtx({
|
|
81
|
+
Resources: {
|
|
82
|
+
PublicSubnet: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.0.0/24" } },
|
|
83
|
+
PublicRouteTable: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
|
|
84
|
+
PublicAssoc: {
|
|
85
|
+
Type: "AWS::EC2::SubnetRouteTableAssociation",
|
|
86
|
+
Properties: { SubnetId: { Ref: "PublicSubnet" }, RouteTableId: { Ref: "PublicRouteTable" } },
|
|
87
|
+
},
|
|
88
|
+
DefaultRoute: {
|
|
89
|
+
Type: "AWS::EC2::Route",
|
|
90
|
+
Properties: { RouteTableId: { Ref: "PublicRouteTable" }, DestinationCidrBlock: "0.0.0.0/0", GatewayId: { Ref: "Igw" } },
|
|
91
|
+
},
|
|
92
|
+
Igw: { Type: "AWS::EC2::InternetGateway", Properties: {} },
|
|
93
|
+
Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(checkPrivateSubnetDefaultRoute(ctx)).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("subnet with no explicit route table association → no diagnostic (out of scope, can't prove)", () => {
|
|
100
|
+
const ctx = makeCtx({
|
|
101
|
+
Resources: {
|
|
102
|
+
Subnet: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24" } },
|
|
103
|
+
Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
expect(checkPrivateSubnetDefaultRoute(ctx)).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW066: Private Subnet Route Table Has No Working Default Route
|
|
3
|
+
*
|
|
4
|
+
* Cross-resource join: a subnet's outbound path depends on a chain of three
|
|
5
|
+
* independently-declared resources — the `AWS::EC2::SubnetRouteTableAssociation`
|
|
6
|
+
* that pins it to a route table, the `AWS::EC2::Route` on that table for
|
|
7
|
+
* `0.0.0.0/0`, and the gateway that route names. CloudFormation validates
|
|
8
|
+
* none of this end-to-end: a subnet can be wired to a route table with no
|
|
9
|
+
* default route at all, or with a default route whose NAT gateway / Transit
|
|
10
|
+
* Gateway target was renamed or removed elsewhere in the template, and the
|
|
11
|
+
* stack still deploys clean. The subnet is black-holed — every deploy
|
|
12
|
+
* succeeds, and the only symptom is a workload with no outbound connectivity.
|
|
13
|
+
*
|
|
14
|
+
* A route table with a default route to an `AWS::EC2::InternetGateway` is
|
|
15
|
+
* treated as a public subnet and is out of scope for this check — this rule
|
|
16
|
+
* only flags the private case: no default route, or one whose target
|
|
17
|
+
* (`NatGatewayId` / `TransitGatewayId` / etc.) does not resolve to a
|
|
18
|
+
* resource declared in the same template. A route naming a target that
|
|
19
|
+
* exists in the template resolves cleanly and is never flagged, per the
|
|
20
|
+
* cross-resource contract.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
24
|
+
import { parseCFTemplate, findResourceRefs, type CFResource, type CFTemplate } from "./cf-refs";
|
|
25
|
+
|
|
26
|
+
type RouteClass = "public" | "resolved" | "dangling";
|
|
27
|
+
|
|
28
|
+
const OTHER_TARGET_KEYS = [
|
|
29
|
+
"NatGatewayId",
|
|
30
|
+
"TransitGatewayId",
|
|
31
|
+
"VpcPeeringConnectionId",
|
|
32
|
+
"EgressOnlyInternetGatewayId",
|
|
33
|
+
"LocalGatewayId",
|
|
34
|
+
"CarrierGatewayId",
|
|
35
|
+
"NetworkInterfaceId",
|
|
36
|
+
"InstanceId",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
/** Classify a `0.0.0.0/0` Route's target. A target Ref that exists always resolves. */
|
|
40
|
+
function classifyDefaultRoute(route: CFResource, resources: Record<string, CFResource>): RouteClass {
|
|
41
|
+
const props = route.Properties ?? {};
|
|
42
|
+
|
|
43
|
+
if (props.GatewayId !== undefined) {
|
|
44
|
+
if (typeof props.GatewayId === "string") {
|
|
45
|
+
return props.GatewayId.startsWith("igw-") ? "public" : "resolved";
|
|
46
|
+
}
|
|
47
|
+
const refs = findResourceRefs(props.GatewayId);
|
|
48
|
+
if (refs.size !== 1) return "resolved"; // unprovable intrinsic — stay quiet
|
|
49
|
+
const [id] = refs;
|
|
50
|
+
if (!resources[id]) return "dangling";
|
|
51
|
+
return resources[id].Type === "AWS::EC2::InternetGateway" ? "public" : "resolved";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const key of OTHER_TARGET_KEYS) {
|
|
55
|
+
const value = (props as Record<string, unknown>)[key];
|
|
56
|
+
if (value === undefined) continue;
|
|
57
|
+
if (typeof value === "string") return "resolved"; // literal id from outside the template
|
|
58
|
+
const refs = findResourceRefs(value);
|
|
59
|
+
if (refs.size !== 1) return "resolved";
|
|
60
|
+
const [id] = refs;
|
|
61
|
+
return resources[id] ? "resolved" : "dangling";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return "resolved"; // no recognized target key — can't classify, stay quiet
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isDefaultRoute(route: CFResource): boolean {
|
|
68
|
+
return route.Properties?.DestinationCidrBlock === "0.0.0.0/0";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function checkPrivateSubnetDefaultRoute(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
72
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
73
|
+
|
|
74
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
75
|
+
const template: CFTemplate | null = parseCFTemplate(output);
|
|
76
|
+
if (!template?.Resources) continue;
|
|
77
|
+
const resources = template.Resources;
|
|
78
|
+
|
|
79
|
+
// subnetId -> Set<routeTableId>, from explicit associations only.
|
|
80
|
+
const subnetRouteTables = new Map<string, Set<string>>();
|
|
81
|
+
for (const resource of Object.values(resources)) {
|
|
82
|
+
if (resource.Type !== "AWS::EC2::SubnetRouteTableAssociation") continue;
|
|
83
|
+
const props = resource.Properties ?? {};
|
|
84
|
+
const subnetRefs = findResourceRefs(props.SubnetId);
|
|
85
|
+
const rtRefs = findResourceRefs(props.RouteTableId);
|
|
86
|
+
for (const subnetId of subnetRefs) {
|
|
87
|
+
let set = subnetRouteTables.get(subnetId);
|
|
88
|
+
if (!set) {
|
|
89
|
+
set = new Set();
|
|
90
|
+
subnetRouteTables.set(subnetId, set);
|
|
91
|
+
}
|
|
92
|
+
for (const rtId of rtRefs) set.add(rtId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// routeTableId -> Route resources targeting it.
|
|
97
|
+
const routesByTable = new Map<string, CFResource[]>();
|
|
98
|
+
for (const resource of Object.values(resources)) {
|
|
99
|
+
if (resource.Type !== "AWS::EC2::Route") continue;
|
|
100
|
+
for (const rtId of findResourceRefs(resource.Properties?.RouteTableId)) {
|
|
101
|
+
let list = routesByTable.get(rtId);
|
|
102
|
+
if (!list) {
|
|
103
|
+
list = [];
|
|
104
|
+
routesByTable.set(rtId, list);
|
|
105
|
+
}
|
|
106
|
+
list.push(resource);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const [subnetId, rtIds] of subnetRouteTables) {
|
|
111
|
+
if (resources[subnetId]?.Type !== "AWS::EC2::Subnet") continue;
|
|
112
|
+
|
|
113
|
+
for (const rtId of rtIds) {
|
|
114
|
+
if (resources[rtId]?.Type !== "AWS::EC2::RouteTable") continue;
|
|
115
|
+
|
|
116
|
+
const routes = routesByTable.get(rtId) ?? [];
|
|
117
|
+
const defaultRoutes = routes.filter(isDefaultRoute);
|
|
118
|
+
|
|
119
|
+
if (defaultRoutes.length === 0) {
|
|
120
|
+
diagnostics.push({
|
|
121
|
+
checkId: "WAW066",
|
|
122
|
+
severity: "warning",
|
|
123
|
+
message: `Subnet "${subnetId}" is associated with route table "${rtId}", which has no default route (0.0.0.0/0) — outbound traffic from this subnet has no path out, black-holed by omission`,
|
|
124
|
+
entity: subnetId,
|
|
125
|
+
lexicon: "aws",
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const classes = defaultRoutes.map((route) => classifyDefaultRoute(route, resources));
|
|
131
|
+
if (classes.includes("public")) continue; // has a real IGW default route — public subnet, out of scope
|
|
132
|
+
if (classes.every((c) => c === "dangling")) {
|
|
133
|
+
diagnostics.push({
|
|
134
|
+
checkId: "WAW066",
|
|
135
|
+
severity: "error",
|
|
136
|
+
message: `Subnet "${subnetId}"'s route table "${rtId}" has a default route (0.0.0.0/0) whose target does not exist in this template — outbound traffic from this subnet is black-holed`,
|
|
137
|
+
entity: subnetId,
|
|
138
|
+
lexicon: "aws",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return diagnostics;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const waw066: PostSynthCheck = {
|
|
149
|
+
id: "WAW066",
|
|
150
|
+
description: "Private subnet's route table has no default route, or its default route targets a gateway that does not exist in the template",
|
|
151
|
+
|
|
152
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
153
|
+
return checkPrivateSubnetDefaultRoute(ctx);
|
|
154
|
+
},
|
|
155
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw067, checkNatGatewaySingleAz } from "./waw067";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("WAW067: Single-AZ NAT Gateway Serves Multi-AZ Private Subnets", () => {
|
|
10
|
+
test("check metadata", () => {
|
|
11
|
+
expect(waw067.id).toBe("WAW067");
|
|
12
|
+
expect(waw067.description).toContain("NAT gateway");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("one NAT gateway serving private subnets in two AZs → warning", () => {
|
|
16
|
+
const ctx = makeCtx({
|
|
17
|
+
Resources: {
|
|
18
|
+
Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
|
|
19
|
+
PublicSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.0.0/24", AvailabilityZone: "us-east-1a" } },
|
|
20
|
+
NatGateway: { Type: "AWS::EC2::NatGateway", Properties: { SubnetId: { Ref: "PublicSubnetA" } } },
|
|
21
|
+
|
|
22
|
+
PrivateSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24", AvailabilityZone: "us-east-1a" } },
|
|
23
|
+
PrivateRouteTableA: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
|
|
24
|
+
PrivateAssocA: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetA" }, RouteTableId: { Ref: "PrivateRouteTableA" } } },
|
|
25
|
+
PrivateDefaultRouteA: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableA" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGateway" } } },
|
|
26
|
+
|
|
27
|
+
PrivateSubnetB: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.2.0/24", AvailabilityZone: "us-east-1b" } },
|
|
28
|
+
PrivateRouteTableB: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
|
|
29
|
+
PrivateAssocB: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetB" }, RouteTableId: { Ref: "PrivateRouteTableB" } } },
|
|
30
|
+
PrivateDefaultRouteB: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableB" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGateway" } } },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const diags = checkNatGatewaySingleAz(ctx);
|
|
34
|
+
expect(diags).toHaveLength(1);
|
|
35
|
+
expect(diags[0].checkId).toBe("WAW067");
|
|
36
|
+
expect(diags[0].severity).toBe("warning");
|
|
37
|
+
expect(diags[0].entity).toBe("NatGateway");
|
|
38
|
+
expect(diags[0].message).toContain("us-east-1a");
|
|
39
|
+
expect(diags[0].message).toContain("us-east-1b");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("one NAT gateway per AZ, each serving only its own AZ's subnets → no diagnostic", () => {
|
|
43
|
+
const ctx = makeCtx({
|
|
44
|
+
Resources: {
|
|
45
|
+
Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
|
|
46
|
+
|
|
47
|
+
PublicSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.0.0/24", AvailabilityZone: "us-east-1a" } },
|
|
48
|
+
NatGatewayA: { Type: "AWS::EC2::NatGateway", Properties: { SubnetId: { Ref: "PublicSubnetA" } } },
|
|
49
|
+
PrivateSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24", AvailabilityZone: "us-east-1a" } },
|
|
50
|
+
PrivateRouteTableA: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
|
|
51
|
+
PrivateAssocA: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetA" }, RouteTableId: { Ref: "PrivateRouteTableA" } } },
|
|
52
|
+
PrivateDefaultRouteA: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableA" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGatewayA" } } },
|
|
53
|
+
|
|
54
|
+
PublicSubnetB: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.10.0/24", AvailabilityZone: "us-east-1b" } },
|
|
55
|
+
NatGatewayB: { Type: "AWS::EC2::NatGateway", Properties: { SubnetId: { Ref: "PublicSubnetB" } } },
|
|
56
|
+
PrivateSubnetB: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.2.0/24", AvailabilityZone: "us-east-1b" } },
|
|
57
|
+
PrivateRouteTableB: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
|
|
58
|
+
PrivateAssocB: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetB" }, RouteTableId: { Ref: "PrivateRouteTableB" } } },
|
|
59
|
+
PrivateDefaultRouteB: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableB" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGatewayB" } } },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
expect(checkNatGatewaySingleAz(ctx)).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("one NAT gateway serving only subnets in its own AZ → no diagnostic", () => {
|
|
66
|
+
const ctx = makeCtx({
|
|
67
|
+
Resources: {
|
|
68
|
+
Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
|
|
69
|
+
PublicSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.0.0/24", AvailabilityZone: "us-east-1a" } },
|
|
70
|
+
NatGateway: { Type: "AWS::EC2::NatGateway", Properties: { SubnetId: { Ref: "PublicSubnetA" } } },
|
|
71
|
+
PrivateSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24", AvailabilityZone: "us-east-1a" } },
|
|
72
|
+
PrivateRouteTableA: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
|
|
73
|
+
PrivateAssocA: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetA" }, RouteTableId: { Ref: "PrivateRouteTableA" } } },
|
|
74
|
+
PrivateDefaultRouteA: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableA" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGateway" } } },
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
expect(checkNatGatewaySingleAz(ctx)).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW067: Single-AZ NAT Gateway Serves Multi-AZ Private Subnets
|
|
3
|
+
*
|
|
4
|
+
* Cross-resource join across four resource kinds: a NAT gateway lives in one
|
|
5
|
+
* subnet, which lives in one Availability Zone; a route table's default
|
|
6
|
+
* route names the NAT gateway it forwards to; a subnet is pinned to a route
|
|
7
|
+
* table by an `AWS::EC2::SubnetRouteTableAssociation`. Follow that chain and
|
|
8
|
+
* a common reliability gap falls out — private subnets in two or more AZs
|
|
9
|
+
* all defaulting to the *same* NAT gateway. CloudFormation has no opinion on
|
|
10
|
+
* this; it deploys cleanly. But a NAT gateway lives entirely inside one AZ,
|
|
11
|
+
* so if that AZ has an outage, every subnet depending on it loses egress —
|
|
12
|
+
* including the subnets in AZs that were otherwise healthy. The
|
|
13
|
+
* Well-Architected fix is one NAT gateway per AZ, each serving only its own
|
|
14
|
+
* AZ's subnets.
|
|
15
|
+
*
|
|
16
|
+
* Only fires when the Availability Zones involved are literal strings —
|
|
17
|
+
* an `Fn::GetAZs`/`Fn::Select`-derived AZ can't be compared statically, so a
|
|
18
|
+
* subnet without a literal `AvailabilityZone` is silently excluded rather
|
|
19
|
+
* than guessed at.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
23
|
+
import { parseCFTemplate, findResourceRefs, type CFTemplate } from "./cf-refs";
|
|
24
|
+
|
|
25
|
+
export function checkNatGatewaySingleAz(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
26
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
27
|
+
|
|
28
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
29
|
+
const template: CFTemplate | null = parseCFTemplate(output);
|
|
30
|
+
if (!template?.Resources) continue;
|
|
31
|
+
const resources = template.Resources;
|
|
32
|
+
|
|
33
|
+
// NAT gateway id -> its own subnet's literal AZ.
|
|
34
|
+
const natGatewayAz = new Map<string, string>();
|
|
35
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
36
|
+
if (resource.Type !== "AWS::EC2::NatGateway") continue;
|
|
37
|
+
const subnetRefs = findResourceRefs(resource.Properties?.SubnetId);
|
|
38
|
+
if (subnetRefs.size !== 1) continue;
|
|
39
|
+
const [subnetId] = subnetRefs;
|
|
40
|
+
const az = resources[subnetId]?.Properties?.AvailabilityZone;
|
|
41
|
+
if (typeof az === "string") natGatewayAz.set(logicalId, az);
|
|
42
|
+
}
|
|
43
|
+
if (natGatewayAz.size === 0) continue;
|
|
44
|
+
|
|
45
|
+
// route table id -> the NAT gateway its default route forwards to.
|
|
46
|
+
const routeTableNatTarget = new Map<string, string>();
|
|
47
|
+
for (const resource of Object.values(resources)) {
|
|
48
|
+
if (resource.Type !== "AWS::EC2::Route") continue;
|
|
49
|
+
const props = resource.Properties ?? {};
|
|
50
|
+
if (props.DestinationCidrBlock !== "0.0.0.0/0") continue;
|
|
51
|
+
const natRefs = findResourceRefs(props.NatGatewayId);
|
|
52
|
+
if (natRefs.size !== 1) continue;
|
|
53
|
+
const [natId] = natRefs;
|
|
54
|
+
if (!natGatewayAz.has(natId)) continue;
|
|
55
|
+
for (const rtId of findResourceRefs(props.RouteTableId)) {
|
|
56
|
+
routeTableNatTarget.set(rtId, natId);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// subnet id -> route table id, from explicit associations only.
|
|
61
|
+
const subnetRouteTable = new Map<string, string>();
|
|
62
|
+
for (const resource of Object.values(resources)) {
|
|
63
|
+
if (resource.Type !== "AWS::EC2::SubnetRouteTableAssociation") continue;
|
|
64
|
+
const props = resource.Properties ?? {};
|
|
65
|
+
const subnetRefs = findResourceRefs(props.SubnetId);
|
|
66
|
+
const rtRefs = findResourceRefs(props.RouteTableId);
|
|
67
|
+
if (subnetRefs.size !== 1 || rtRefs.size !== 1) continue;
|
|
68
|
+
const [subnetId] = subnetRefs;
|
|
69
|
+
const [rtId] = rtRefs;
|
|
70
|
+
subnetRouteTable.set(subnetId, rtId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// NAT gateway id -> the set of subnets that default-route through it.
|
|
74
|
+
const servedSubnets = new Map<string, Set<string>>();
|
|
75
|
+
for (const [subnetId, rtId] of subnetRouteTable) {
|
|
76
|
+
const natId = routeTableNatTarget.get(rtId);
|
|
77
|
+
if (!natId) continue;
|
|
78
|
+
let set = servedSubnets.get(natId);
|
|
79
|
+
if (!set) {
|
|
80
|
+
set = new Set();
|
|
81
|
+
servedSubnets.set(natId, set);
|
|
82
|
+
}
|
|
83
|
+
set.add(subnetId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [natId, subnetIds] of servedSubnets) {
|
|
87
|
+
const azs = new Set<string>();
|
|
88
|
+
for (const subnetId of subnetIds) {
|
|
89
|
+
const az = resources[subnetId]?.Properties?.AvailabilityZone;
|
|
90
|
+
if (typeof az === "string") azs.add(az);
|
|
91
|
+
}
|
|
92
|
+
if (azs.size > 1) {
|
|
93
|
+
diagnostics.push({
|
|
94
|
+
checkId: "WAW067",
|
|
95
|
+
severity: "warning",
|
|
96
|
+
message: `NAT Gateway "${natId}" (in ${natGatewayAz.get(natId)}) is the default-route egress for private subnets across ${azs.size} Availability Zones (${[...azs].sort().join(", ")}) — a single-AZ NAT gateway is a single point of failure for cross-AZ egress; add one NAT gateway per AZ`,
|
|
97
|
+
entity: natId,
|
|
98
|
+
lexicon: "aws",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return diagnostics;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const waw067: PostSynthCheck = {
|
|
108
|
+
id: "WAW067",
|
|
109
|
+
description: "A single-AZ NAT gateway serves private subnets across multiple Availability Zones — single point of failure for cross-AZ egress",
|
|
110
|
+
|
|
111
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
112
|
+
return checkNatGatewaySingleAz(ctx);
|
|
113
|
+
},
|
|
114
|
+
};
|