@intentius/chant-lexicon-aws 0.33.1 → 0.34.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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Which resources the provider created, rather than anyone's infrastructure code.
3
+ *
4
+ * Every account arrives with resources nobody wrote: a default VPC and its
5
+ * subnets, a default security group per VPC, a main route table, AWS-managed
6
+ * KMS keys and IAM policies. They are yours to account for and were never
7
+ * yours to declare, and until now chant recorded them looking exactly like
8
+ * everything else.
9
+ *
10
+ * That matters wherever a question is about what you built. Asked which
11
+ * security groups were unused, agents split three ways on the same correct set
12
+ * of four — one excluded the three VPC defaults as "not real cleanup
13
+ * candidates" and answered one, another counted something extra and answered
14
+ * five. The answers that were accepted all did the same thing: listed all four
15
+ * and said which were defaults. They could only do that by recognising them,
16
+ * and chant gave them nothing to recognise them by.
17
+ *
18
+ * This records a fact and stops. `providerDefault: true` says the provider
19
+ * created this; it does not say the resource is unused, safe to ignore, or
20
+ * exempt from anything. Deciding that here would put a conclusion in an
21
+ * observation — the mistake `liveInternetFacing` made and #1271 undid — and it
22
+ * would be wrong in this very case, since the accepted answers count defaults
23
+ * as unused.
24
+ *
25
+ * Almost none of this is chant's judgement. AWS marks these itself, on the same
26
+ * payloads chant already reads; the fields were simply being dropped. The one
27
+ * derivation is the security group, and it is safe because `default` is a
28
+ * reserved group name — AWS rejects it on create, so a group called `default`
29
+ * is that VPC's default group.
30
+ */
31
+
32
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
33
+
34
+ type Attrs = Record<string, unknown>;
35
+
36
+ /**
37
+ * Per kind, the provider's own marker. Extending this is the way to widen.
38
+ *
39
+ * Each predicate reads a field AWS already returns, so this is a passthrough
40
+ * rather than a heuristic — a resource is a default because the provider says
41
+ * so, not because it looks like one.
42
+ */
43
+ const PROVIDER_DEFAULT: Record<string, (attrs: Attrs) => boolean> = {
44
+ "AWS::EC2::VPC": (a) => a.IsDefault === true,
45
+ "AWS::EC2::Subnet": (a) => a.DefaultForAz === true,
46
+ "AWS::EC2::NetworkAcl": (a) => a.IsDefault === true,
47
+ // `default` is a reserved group name: AWS refuses it on create, so a group
48
+ // carrying it is the one AWS made with the VPC.
49
+ "AWS::EC2::SecurityGroup": (a) => a.GroupName === "default",
50
+ // The main route table is the one a subnet falls back to when it has no
51
+ // explicit association — created with the VPC and not by anyone.
52
+ "AWS::EC2::RouteTable": (a) =>
53
+ Array.isArray(a.Associations) &&
54
+ a.Associations.some((x) => (x as Attrs | null)?.Main === true),
55
+ "AWS::KMS::Key": (a) => a.KeyManager === "AWS",
56
+ "AWS::IAM::ManagedPolicy": (a) =>
57
+ typeof a.Arn === "string" && a.Arn.startsWith("arn:aws:iam::aws:policy/"),
58
+ "AWS::IAM::Policy": (a) =>
59
+ typeof a.Arn === "string" && a.Arn.startsWith("arn:aws:iam::aws:policy/"),
60
+ };
61
+
62
+ /** True when this lexicon can tell whether the kind is a provider default. */
63
+ export function canDetectDefault(kind: string): boolean {
64
+ return kind in PROVIDER_DEFAULT;
65
+ }
66
+
67
+ /** The kinds whose provider-default status this lexicon can report. */
68
+ export const DEFAULT_AWARE_KINDS: string[] = Object.keys(PROVIDER_DEFAULT);
69
+
70
+ /**
71
+ * Mark the resources the provider created.
72
+ *
73
+ * Only ever sets the attribute to `true`, and only for kinds with a known
74
+ * marker: absent means "not a default, or chant cannot tell", and those are
75
+ * genuinely different from each other in a way a blanket `false` would hide.
76
+ */
77
+ export function stampProviderDefaults(
78
+ resources: Record<string, ResourceMetadata>,
79
+ ): Record<string, ResourceMetadata> {
80
+ const stamped: Record<string, ResourceMetadata> = {};
81
+ for (const [name, meta] of Object.entries(resources)) {
82
+ const test = PROVIDER_DEFAULT[meta.type];
83
+ const attrs = (meta.attributes ?? {}) as Attrs;
84
+ let isDefault = false;
85
+ try {
86
+ isDefault = test ? test(attrs) : false;
87
+ } catch {
88
+ // A malformed payload is not a default; it is a payload chant could not
89
+ // read, and the rest of the observation is still good.
90
+ isDefault = false;
91
+ }
92
+ stamped[name] = isDefault
93
+ ? { ...meta, attributes: { ...attrs, providerDefault: true } }
94
+ : meta;
95
+ }
96
+ return stamped;
97
+ }
@@ -0,0 +1,208 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Every AWS interaction is a mocked `spawn` — the reader's only edge is the
4
+ // runtime adapter, and nothing here constructs a client or reaches a network.
5
+ const spawnMock = vi.fn();
6
+ vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
8
+ return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
9
+ });
10
+
11
+ const { toIngressRules, observeAwsDependencies } = await import("./dependencies");
12
+
13
+ const ok = (body: unknown) => ({ stdout: JSON.stringify(body), stderr: "", exitCode: 0 });
14
+
15
+ /** Route the mock by the AWS subcommand, so ordering is not load-bearing. */
16
+ function respond(handlers: Record<string, unknown>) {
17
+ spawnMock.mockImplementation((...args: unknown[]) => {
18
+ const argv = (args[0] ?? []) as string[];
19
+ const at = argv.indexOf("aws");
20
+ const key = argv.slice(at + 1, at + 3).join(" ");
21
+ const body = handlers[key];
22
+ return Promise.resolve(body ? ok(body) : { stdout: "", stderr: "not mocked: " + key, exitCode: 255 });
23
+ });
24
+ }
25
+
26
+ // #1276 — the two AWS surfaces disagree about the same concept. A template's
27
+ // SecurityGroupIngress carries CidrIp flat; describe-security-groups nests
28
+ // sources under IpRanges[]/Ipv6Ranges[]/UserIdGroupPairs[], and one permission
29
+ // can hold several. Handing the describe shape to the fold unchanged renders
30
+ // every source as `?`, which matches no CIDR query and narrows the answer.
31
+ describe("toIngressRules (#1276)", () => {
32
+ it("flattens an IPv4 range into the flat rule the fold reads", () => {
33
+ expect(
34
+ toIngressRules([{ IpProtocol: "tcp", FromPort: 22, ToPort: 22, IpRanges: [{ CidrIp: "0.0.0.0/0" }] }]),
35
+ ).toEqual([{ IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "0.0.0.0/0" }]);
36
+ });
37
+
38
+ it("emits one rule per source, because that is what the flat shape means", () => {
39
+ const rules = toIngressRules([
40
+ {
41
+ IpProtocol: "tcp",
42
+ FromPort: 443,
43
+ ToPort: 443,
44
+ IpRanges: [{ CidrIp: "10.0.0.0/8" }, { CidrIp: "192.168.0.0/16" }],
45
+ UserIdGroupPairs: [{ GroupId: "sg-peer" }],
46
+ },
47
+ ]);
48
+ expect(rules).toHaveLength(3);
49
+ expect(rules.map((r) => r.CidrIp ?? r.SourceSecurityGroupId)).toEqual([
50
+ "10.0.0.0/8",
51
+ "192.168.0.0/16",
52
+ "sg-peer",
53
+ ]);
54
+ });
55
+
56
+ it("carries IPv6 sources through their own key", () => {
57
+ expect(toIngressRules([{ IpProtocol: "tcp", Ipv6Ranges: [{ CidrIpv6: "::/0" }] }])).toEqual([
58
+ { IpProtocol: "tcp", CidrIpv6: "::/0" },
59
+ ]);
60
+ });
61
+
62
+ it("an all-protocols rule keeps the -1 the fold expects, and omits absent ports", () => {
63
+ // FromPort absent is "all ports" to normalizeIngress; emitting FromPort:
64
+ // undefined would render as a port range rather than `all`.
65
+ expect(toIngressRules([{ IpRanges: [{ CidrIp: "0.0.0.0/0" }] }])).toEqual([
66
+ { IpProtocol: "-1", CidrIp: "0.0.0.0/0" },
67
+ ]);
68
+ });
69
+
70
+ it("a permission with no source contributes nothing", () => {
71
+ expect(toIngressRules([{ IpProtocol: "tcp", FromPort: 22, ToPort: 22 }])).toEqual([]);
72
+ });
73
+ });
74
+
75
+
76
+ // #1276 — the case the benchmark estate deliberately cannot exercise, because
77
+ // it declares every security group. The fix matters precisely where an instance
78
+ // is guarded by a group nobody declared, so it is proven here rather than by
79
+ // changing a scenario a published comparison depends on.
80
+ describe("undeclared security groups (#1276)", () => {
81
+ beforeEach(() => spawnMock.mockReset());
82
+
83
+ const observedInstanceOnly = {
84
+ webServer: { type: "AWS::EC2::Instance", status: "CREATE_COMPLETE", physicalId: "i-1" },
85
+ };
86
+
87
+ it("reports a security group the estate never declared, with its rules", async () => {
88
+ respond({
89
+ "ec2 describe-instances": {
90
+ Reservations: [
91
+ {
92
+ Instances: [
93
+ { InstanceId: "i-1", SubnetId: "subnet-1", VpcId: "vpc-1", SecurityGroups: [{ GroupId: "sg-shared" }] },
94
+ ],
95
+ },
96
+ ],
97
+ },
98
+ "ec2 describe-route-tables": { RouteTables: [] },
99
+ "ec2 describe-security-groups": {
100
+ SecurityGroups: [
101
+ {
102
+ GroupId: "sg-shared",
103
+ VpcId: "vpc-1",
104
+ IpPermissions: [
105
+ { IpProtocol: "tcp", FromPort: 22, ToPort: 22, IpRanges: [{ CidrIp: "0.0.0.0/0" }] },
106
+ ],
107
+ },
108
+ ],
109
+ },
110
+ });
111
+
112
+ const { resources, edges } = await observeAwsDependencies({ observed: observedInstanceOnly });
113
+
114
+ // The group is a node now, so the fold can read its rules at all.
115
+ expect(resources["sg-shared"]).toMatchObject({
116
+ type: "AWS::EC2::SecurityGroup",
117
+ physicalId: "sg-shared",
118
+ referencedBy: ["webServer"],
119
+ });
120
+ // In the flat shape the fold reads — not the nested describe shape, which
121
+ // would render every source as `?` and match no CIDR query.
122
+ expect(resources["sg-shared"].attributes?.SecurityGroupIngress).toEqual([
123
+ { IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "0.0.0.0/0" },
124
+ ]);
125
+ expect(edges).toContainEqual({ from: "webServer", to: "sg-shared", kind: "ref", viaAttr: "SecurityGroupIds" });
126
+ });
127
+
128
+ it("does not report a group the estate already declares", async () => {
129
+ respond({
130
+ "ec2 describe-instances": {
131
+ Reservations: [
132
+ { Instances: [{ InstanceId: "i-1", SubnetId: "subnet-1", SecurityGroups: [{ GroupId: "sg-mine" }] }] },
133
+ ],
134
+ },
135
+ "ec2 describe-route-tables": { RouteTables: [] },
136
+ });
137
+
138
+ const { resources } = await observeAwsDependencies({
139
+ observed: {
140
+ ...observedInstanceOnly,
141
+ webSg: { type: "AWS::EC2::SecurityGroup", status: "CREATE_COMPLETE", physicalId: "sg-mine" },
142
+ },
143
+ });
144
+
145
+ // Declared resources are the managed observation's job. Reporting one here
146
+ // too would double it as both managed and dependency.
147
+ expect(resources["sg-mine"]).toBeUndefined();
148
+ });
149
+
150
+ it("follows a launch template to the groups it attaches", async () => {
151
+ respond({
152
+ "ec2 describe-instances": {
153
+ Reservations: [
154
+ {
155
+ Instances: [
156
+ {
157
+ InstanceId: "i-1",
158
+ SubnetId: "subnet-1",
159
+ SecurityGroups: [],
160
+ LaunchTemplate: { LaunchTemplateId: "lt-1" },
161
+ },
162
+ ],
163
+ },
164
+ ],
165
+ },
166
+ "ec2 describe-route-tables": { RouteTables: [] },
167
+ "ec2 describe-launch-template-versions": {
168
+ LaunchTemplateVersions: [{ LaunchTemplateData: { SecurityGroupIds: ["sg-via-lt"] } }],
169
+ },
170
+ "ec2 describe-security-groups": {
171
+ SecurityGroups: [
172
+ {
173
+ GroupId: "sg-via-lt",
174
+ IpPermissions: [
175
+ { IpProtocol: "tcp", FromPort: 22, ToPort: 22, IpRanges: [{ CidrIp: "0.0.0.0/0" }] },
176
+ ],
177
+ },
178
+ ],
179
+ },
180
+ });
181
+
182
+ const { resources, edges } = await observeAwsDependencies({ observed: observedInstanceOnly });
183
+
184
+ // The indirect hop — the one a flat describe-instances sweep misses, and
185
+ // the reason effectiveIngress is a fold rather than a passthrough.
186
+ expect(resources["lt-1"]).toMatchObject({ type: "AWS::EC2::LaunchTemplate" });
187
+ expect(resources["sg-via-lt"]).toMatchObject({ type: "AWS::EC2::SecurityGroup" });
188
+ expect(edges).toContainEqual({ from: "webServer", to: "lt-1", kind: "ref", viaAttr: "LaunchTemplateId" });
189
+ expect(edges).toContainEqual({ from: "lt-1", to: "sg-via-lt", kind: "ref", viaAttr: "SecurityGroupIds" });
190
+ });
191
+
192
+ it("an instance with no internet route still reports its groups", async () => {
193
+ // effectiveIngress is asked about instances that are not internet-facing
194
+ // too; guarding must not be conditional on routing.
195
+ respond({
196
+ "ec2 describe-instances": {
197
+ Reservations: [
198
+ { Instances: [{ InstanceId: "i-1", SubnetId: "subnet-private", SecurityGroups: [{ GroupId: "sg-shared" }] }] },
199
+ ],
200
+ },
201
+ "ec2 describe-route-tables": { RouteTables: [] },
202
+ "ec2 describe-security-groups": { SecurityGroups: [{ GroupId: "sg-shared", IpPermissions: [] }] },
203
+ });
204
+
205
+ const { resources } = await observeAwsDependencies({ observed: observedInstanceOnly });
206
+ expect(resources["sg-shared"]).toBeDefined();
207
+ });
208
+ });
@@ -0,0 +1,323 @@
1
+ /**
2
+ * What an AWS estate depends on but does not declare (#1273).
3
+ *
4
+ * `describeResources` is scoped to the stack. An instance placed in the
5
+ * account's default VPC routes through a route table nobody declared, so that
6
+ * table is never observed, never a node, and no fold can traverse to it. The
7
+ * answer used to be computed inside the lexicon and injected as an attribute —
8
+ * which is a conclusion no snapshot can record enough to recompute.
9
+ *
10
+ * This reports the resources instead, so the one graph fold derives the answer.
11
+ *
12
+ * The closure is bounded: start from the observed instances, follow the
13
+ * reference chain the catalog declares as meaningful — subnet, then route
14
+ * table, then gateway — and stop. Anything not reached from a declared resource
15
+ * along a declared reference is not a dependency, it is just the account.
16
+ */
17
+
18
+ import { applyAwsEndpointArgv } from "./components/cloud-executor";
19
+ import type { DependencyObservation, ResourceMetadata, IREdge } from "@intentius/chant/lexicon";
20
+
21
+ /** Cloud-shaped ids are stable across a redeploy and unique per account. */
22
+ const nodeId = (physicalId: string): string => physicalId;
23
+
24
+ /** One ingress permission as `describe-security-groups` returns it. */
25
+ interface RawIpPermission {
26
+ IpProtocol?: string;
27
+ FromPort?: number;
28
+ ToPort?: number;
29
+ IpRanges?: Array<{ CidrIp?: string }>;
30
+ Ipv6Ranges?: Array<{ CidrIpv6?: string }>;
31
+ UserIdGroupPairs?: Array<{ GroupId?: string }>;
32
+ }
33
+
34
+ /**
35
+ * Flatten `IpPermissions` into the CloudFormation-shaped rules the fold reads.
36
+ *
37
+ * The two AWS surfaces disagree about the same concept: a template's
38
+ * `SecurityGroupIngress` carries `CidrIp` flat on the rule, while
39
+ * `describe-security-groups` nests sources under `IpRanges[]`, `Ipv6Ranges[]`
40
+ * and `UserIdGroupPairs[]`, and one permission can hold several. Handing the
41
+ * describe shape to the fold unchanged yields a rule with no recognisable
42
+ * source, which `normalizeIngress` renders as `?` — an ingress rule that
43
+ * matches no CIDR query and quietly narrows the answer.
44
+ *
45
+ * One rule per source, because that is what the flat shape means.
46
+ */
47
+ export function toIngressRules(permissions: RawIpPermission[]): Array<Record<string, unknown>> {
48
+ const rules: Array<Record<string, unknown>> = [];
49
+ for (const permission of permissions) {
50
+ const base = {
51
+ IpProtocol: permission.IpProtocol ?? "-1",
52
+ ...(permission.FromPort != null ? { FromPort: permission.FromPort } : {}),
53
+ ...(permission.ToPort != null ? { ToPort: permission.ToPort } : {}),
54
+ };
55
+ for (const range of permission.IpRanges ?? []) {
56
+ if (range.CidrIp) rules.push({ ...base, CidrIp: range.CidrIp });
57
+ }
58
+ for (const range of permission.Ipv6Ranges ?? []) {
59
+ if (range.CidrIpv6) rules.push({ ...base, CidrIpv6: range.CidrIpv6 });
60
+ }
61
+ for (const pair of permission.UserIdGroupPairs ?? []) {
62
+ if (pair.GroupId) rules.push({ ...base, SourceSecurityGroupId: pair.GroupId });
63
+ }
64
+ }
65
+ return rules;
66
+ }
67
+
68
+ interface RawRouteTable {
69
+ RouteTableId?: string;
70
+ VpcId?: string;
71
+ Routes?: Array<{ GatewayId?: string; DestinationCidrBlock?: string }>;
72
+ Associations?: Array<{ SubnetId?: string; Main?: boolean }>;
73
+ }
74
+
75
+ /**
76
+ * Read the routing an observed estate depends on.
77
+ *
78
+ * Only the tables that actually serve an observed instance are reported: one
79
+ * reached by an explicit subnet association, or the VPC's main table standing in
80
+ * for a subnet with none. A route table serving nothing chant deployed is not a
81
+ * dependency of this estate.
82
+ */
83
+ export async function observeAwsDependencies(options: {
84
+ observed: Record<string, ResourceMetadata>;
85
+ region?: string;
86
+ }): Promise<DependencyObservation> {
87
+ const { getRuntime } = await import("@intentius/chant/runtime-adapter");
88
+ const rt = getRuntime();
89
+ const regionArgs = options.region ? ["--region", options.region] : [];
90
+ const run = (args: string[]) =>
91
+ rt.spawn(applyAwsEndpointArgv(["aws", ...args, ...regionArgs, "--output", "json"], process.env.AWS_ENDPOINT_URL));
92
+
93
+ const resources: Record<string, ResourceMetadata> = {};
94
+ const edges: IREdge[] = [];
95
+
96
+ // Roots: the instances this estate manages, and where each one sits. Placement
97
+ // comes from the live API rather than the template, because the declared side
98
+ // may only carry a parameter reference to a subnet it never modelled.
99
+ const instances = Object.entries(options.observed).filter(
100
+ ([, meta]) => meta.type === "AWS::EC2::Instance" && meta.physicalId,
101
+ );
102
+ if (instances.length === 0) return { resources: {}, edges: [] };
103
+
104
+ try {
105
+ const described = await run([
106
+ "ec2",
107
+ "describe-instances",
108
+ "--instance-ids",
109
+ ...instances.map(([, meta]) => meta.physicalId as string),
110
+ ]);
111
+ if (described.exitCode !== 0) return { resources: {}, edges: [] };
112
+
113
+ const placement = new Map<
114
+ string,
115
+ { subnetId?: string; vpcId?: string; securityGroupIds: string[]; launchTemplateId?: string }
116
+ >();
117
+ for (const reservation of (JSON.parse(described.stdout).Reservations ?? []) as Array<{
118
+ Instances?: Array<{
119
+ InstanceId: string;
120
+ SubnetId?: string;
121
+ VpcId?: string;
122
+ SecurityGroups?: Array<{ GroupId?: string }>;
123
+ LaunchTemplate?: { LaunchTemplateId?: string };
124
+ }>;
125
+ }>) {
126
+ for (const instance of reservation.Instances ?? []) {
127
+ placement.set(instance.InstanceId, {
128
+ subnetId: instance.SubnetId,
129
+ vpcId: instance.VpcId,
130
+ securityGroupIds: (instance.SecurityGroups ?? [])
131
+ .map((g) => g.GroupId)
132
+ .filter((id): id is string => typeof id === "string"),
133
+ ...(instance.LaunchTemplate?.LaunchTemplateId
134
+ ? { launchTemplateId: instance.LaunchTemplate.LaunchTemplateId }
135
+ : {}),
136
+ });
137
+ }
138
+ }
139
+
140
+ const tablesResult = await run(["ec2", "describe-route-tables"]);
141
+ if (tablesResult.exitCode !== 0) return { resources: {}, edges: [] };
142
+ const tables = (JSON.parse(tablesResult.stdout).RouteTables ?? []) as RawRouteTable[];
143
+
144
+ // Which table serves which subnet, and which is a VPC's main table — the
145
+ // two ways an instance's subnet resolves to routing.
146
+ const bySubnet = new Map<string, RawRouteTable>();
147
+ const mainByVpc = new Map<string, RawRouteTable>();
148
+ for (const table of tables) {
149
+ for (const association of table.Associations ?? []) {
150
+ if (association.SubnetId) bySubnet.set(association.SubnetId, table);
151
+ if (association.Main && table.VpcId) mainByVpc.set(table.VpcId, table);
152
+ }
153
+ }
154
+
155
+ // Security groups and launch templates the estate is guarded by (#1276).
156
+ // `effectiveIngress` resolved these from the declared graph alone, which is
157
+ // right until an instance sits in a group it did not declare — a shared
158
+ // group, one attached through someone else's launch template. Then the
159
+ // group is not a node, its rules are never read, and the instance reads as
160
+ // less exposed than it is. Under-reporting is the dangerous direction for a
161
+ // query whose whole purpose is finding what the internet can reach.
162
+ const declaredGroupIds = new Set(
163
+ Object.values(options.observed)
164
+ .filter((m) => m.type === "AWS::EC2::SecurityGroup" && m.physicalId)
165
+ .map((m) => m.physicalId as string),
166
+ );
167
+ const declaredTemplateIds = new Set(
168
+ Object.values(options.observed)
169
+ .filter((m) => m.type === "AWS::EC2::LaunchTemplate" && m.physicalId)
170
+ .map((m) => m.physicalId as string),
171
+ );
172
+ const groupsById = new Map<string, Record<string, unknown>>();
173
+ const wantedGroups = new Set<string>();
174
+ const templatesById = new Map<string, string[]>();
175
+
176
+ for (const [, meta] of instances) {
177
+ const where = placement.get(meta.physicalId as string);
178
+ if (!where) continue;
179
+ for (const id of where.securityGroupIds) if (!declaredGroupIds.has(id)) wantedGroups.add(id);
180
+ if (where.launchTemplateId && !declaredTemplateIds.has(where.launchTemplateId)) {
181
+ const versions = await run([
182
+ "ec2",
183
+ "describe-launch-template-versions",
184
+ "--launch-template-id",
185
+ where.launchTemplateId,
186
+ "--versions",
187
+ "$Latest",
188
+ ]);
189
+ if (versions.exitCode === 0) {
190
+ const templateGroups = (
191
+ (JSON.parse(versions.stdout).LaunchTemplateVersions ?? []) as Array<{
192
+ LaunchTemplateData?: { SecurityGroupIds?: string[] };
193
+ }>
194
+ ).flatMap((v) => v.LaunchTemplateData?.SecurityGroupIds ?? []);
195
+ templatesById.set(where.launchTemplateId, templateGroups);
196
+ for (const id of templateGroups) if (!declaredGroupIds.has(id)) wantedGroups.add(id);
197
+ }
198
+ }
199
+ }
200
+
201
+ if (wantedGroups.size > 0) {
202
+ const described = await run(["ec2", "describe-security-groups", "--group-ids", ...wantedGroups]);
203
+ if (described.exitCode === 0) {
204
+ for (const group of (JSON.parse(described.stdout).SecurityGroups ?? []) as Array<{
205
+ GroupId?: string;
206
+ VpcId?: string;
207
+ IpPermissions?: RawIpPermission[];
208
+ }>) {
209
+ if (!group.GroupId) continue;
210
+ groupsById.set(group.GroupId, {
211
+ GroupId: group.GroupId,
212
+ VpcId: group.VpcId,
213
+ SecurityGroupIngress: toIngressRules(group.IpPermissions ?? []),
214
+ });
215
+ }
216
+ }
217
+ }
218
+
219
+ const record = (
220
+ logicalId: string,
221
+ id: string,
222
+ type: string,
223
+ physicalId: string | undefined,
224
+ attrs: Record<string, unknown>,
225
+ ) => {
226
+ const existing = resources[id];
227
+ resources[id] = {
228
+ type,
229
+ status: "OBSERVED",
230
+ ...(physicalId ? { physicalId } : {}),
231
+ attributes: attrs,
232
+ ownership: "foreign",
233
+ referencedBy: [...new Set([...(existing?.referencedBy ?? []), logicalId])],
234
+ };
235
+ };
236
+
237
+ for (const [logicalId, meta] of instances) {
238
+ const where = placement.get(meta.physicalId as string);
239
+ if (!where) continue;
240
+
241
+ // Guarding first, and unconditionally: an instance with no internet route
242
+ // still has security groups, and `effectiveIngress` is asked about
243
+ // instances that are not internet-facing too.
244
+ for (const groupId of where.securityGroupIds) {
245
+ const attrs = groupsById.get(groupId);
246
+ if (!attrs) continue; // declared, or unreadable — either way not a dependency
247
+ record(logicalId, nodeId(groupId), "AWS::EC2::SecurityGroup", groupId, attrs);
248
+ edges.push({ from: logicalId, to: nodeId(groupId), kind: "ref", viaAttr: "SecurityGroupIds" });
249
+ }
250
+ const templateGroups = where.launchTemplateId ? templatesById.get(where.launchTemplateId) : undefined;
251
+ if (where.launchTemplateId && templateGroups) {
252
+ const templateNode = nodeId(where.launchTemplateId);
253
+ record(logicalId, templateNode, "AWS::EC2::LaunchTemplate", where.launchTemplateId, {
254
+ LaunchTemplateId: where.launchTemplateId,
255
+ LaunchTemplateData: { SecurityGroupIds: templateGroups },
256
+ });
257
+ edges.push({ from: logicalId, to: templateNode, kind: "ref", viaAttr: "LaunchTemplateId" });
258
+ for (const groupId of templateGroups) {
259
+ const attrs = groupsById.get(groupId);
260
+ if (!attrs) continue;
261
+ record(logicalId, nodeId(groupId), "AWS::EC2::SecurityGroup", groupId, attrs);
262
+ edges.push({ from: templateNode, to: nodeId(groupId), kind: "ref", viaAttr: "SecurityGroupIds" });
263
+ }
264
+ }
265
+
266
+ // Explicit subnet association first, else the VPC's main table — the two
267
+ // ways an instance's placement resolves to routing.
268
+ const table: RawRouteTable | undefined =
269
+ (where.subnetId ? bySubnet.get(where.subnetId) : undefined) ??
270
+ (where.vpcId ? mainByVpc.get(where.vpcId) : undefined);
271
+ if (!table?.RouteTableId) continue;
272
+
273
+ // Only routing that actually reaches the internet is worth reporting: an
274
+ // internal table adds a node and an edge and answers nothing.
275
+ const igwRoute = (table.Routes ?? []).find(
276
+ (route) =>
277
+ typeof route.GatewayId === "string" &&
278
+ route.GatewayId.startsWith("igw-") &&
279
+ (route.DestinationCidrBlock == null || route.DestinationCidrBlock === "0.0.0.0/0"),
280
+ );
281
+ if (!igwRoute?.GatewayId) continue;
282
+
283
+ const subnetNode = where.subnetId ? nodeId(where.subnetId) : undefined;
284
+ const tableNode = nodeId(table.RouteTableId);
285
+ const gatewayNode = nodeId(igwRoute.GatewayId);
286
+ const associationNode = `${tableNode}::assoc::${subnetNode ?? table.VpcId ?? "main"}`;
287
+ const routeNode = `${tableNode}::route::${gatewayNode}`;
288
+
289
+ if (subnetNode) record(logicalId, subnetNode, "AWS::EC2::Subnet", where.subnetId, { VpcId: where.vpcId });
290
+ record(logicalId, tableNode, "AWS::EC2::RouteTable", table.RouteTableId, { VpcId: table.VpcId });
291
+ record(logicalId, gatewayNode, "AWS::EC2::InternetGateway", igwRoute.GatewayId, {
292
+ InternetGatewayId: igwRoute.GatewayId,
293
+ });
294
+ record(logicalId, associationNode, "AWS::EC2::SubnetRouteTableAssociation", undefined, {
295
+ SubnetId: where.subnetId,
296
+ RouteTableId: table.RouteTableId,
297
+ });
298
+ record(logicalId, routeNode, "AWS::EC2::Route", undefined, {
299
+ RouteTableId: table.RouteTableId,
300
+ GatewayId: igwRoute.GatewayId,
301
+ DestinationCidrBlock: igwRoute.DestinationCidrBlock ?? "0.0.0.0/0",
302
+ });
303
+
304
+ // The chain enrichEffectiveTopology walks, reported explicitly rather
305
+ // than left to be reconstructed: an association and a route have no
306
+ // physical id of their own, so no identity index can resolve them.
307
+ if (subnetNode) {
308
+ edges.push({ from: logicalId, to: subnetNode, kind: "ref", viaAttr: "SubnetId" });
309
+ edges.push({ from: associationNode, to: subnetNode, kind: "ref", viaAttr: "SubnetId" });
310
+ }
311
+ edges.push({ from: associationNode, to: tableNode, kind: "ref", viaAttr: "RouteTableId" });
312
+ edges.push({ from: routeNode, to: tableNode, kind: "ref", viaAttr: "RouteTableId" });
313
+ edges.push({ from: routeNode, to: gatewayNode, kind: "ref", viaAttr: "GatewayId" });
314
+ }
315
+ } catch {
316
+ // Best-effort: the managed observation is complete and useful on its own,
317
+ // and failing it because an ambient dependency could not be read would
318
+ // trade a whole answer for a partial one.
319
+ return { resources: {}, edges: [] };
320
+ }
321
+
322
+ return { resources, edges };
323
+ }