@intentius/chant-lexicon-aws 0.33.0 → 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.
- package/dist/ambient.d.ts +39 -0
- package/dist/ambient.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +5 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/defaults.d.ts +44 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/dependencies.d.ts +60 -0
- package/dist/dependencies.d.ts.map +1 -0
- package/dist/generated/index.d.ts +61 -136
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +577 -2759
- package/dist/plugin.d.ts.map +1 -1
- package/dist/properties.d.ts +48 -0
- package/dist/properties.d.ts.map +1 -0
- package/dist/reference-catalog.d.ts.map +1 -1
- package/dist/types/index.d.ts +1206 -2296
- package/package.json +2 -2
- package/src/ambient.test.ts +71 -0
- package/src/ambient.ts +147 -0
- package/src/deep-observe.ts +9 -0
- package/src/defaults.test.ts +73 -0
- package/src/defaults.ts +97 -0
- package/src/dependencies.test.ts +208 -0
- package/src/dependencies.ts +323 -0
- package/src/generated/index.d.ts +1206 -2296
- package/src/generated/index.ts +62 -137
- package/src/generated/lexicon-aws.json +577 -2759
- package/src/plugin.ts +65 -64
- package/src/properties.test.ts +113 -0
- package/src/properties.ts +166 -0
- package/src/reference-catalog.ts +24 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-aws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"typescript": "^5.9.3"
|
|
81
81
|
},
|
|
82
82
|
"peerDependencies": {
|
|
83
|
-
"@intentius/chant": "^0.
|
|
83
|
+
"@intentius/chant": "^0.34.0",
|
|
84
84
|
"typescript": "^5.9.3"
|
|
85
85
|
}
|
|
86
86
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const spawnMock = vi.fn();
|
|
4
|
+
vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
|
|
6
|
+
return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const { observeAwsAmbient, canEnumerate } = await import("./ambient");
|
|
10
|
+
|
|
11
|
+
const ok = (body: unknown) => ({ stdout: JSON.stringify(body), stderr: "", exitCode: 0 });
|
|
12
|
+
const GROUPS = {
|
|
13
|
+
SecurityGroups: [
|
|
14
|
+
{ GroupId: "sg-default", GroupName: "default", VpcId: "vpc-1" },
|
|
15
|
+
{ GroupId: "sg-mine", GroupName: "web", VpcId: "vpc-1" },
|
|
16
|
+
{ GroupId: "sg-stray", GroupName: "leftover", VpcId: "vpc-1" },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// #1278 — a resource nothing points at is invisible to both other observation
|
|
21
|
+
// paths, because both resolve outward from what is declared. "Which of my
|
|
22
|
+
// security groups are unused" is a question about exactly those.
|
|
23
|
+
describe("observeAwsAmbient (#1278)", () => {
|
|
24
|
+
beforeEach(() => spawnMock.mockReset());
|
|
25
|
+
|
|
26
|
+
it("reports groups that exist but are not managed", async () => {
|
|
27
|
+
spawnMock.mockResolvedValue(ok(GROUPS));
|
|
28
|
+
const found = await observeAwsAmbient({
|
|
29
|
+
kinds: ["AWS::EC2::SecurityGroup"],
|
|
30
|
+
observed: { web: { type: "AWS::EC2::SecurityGroup", status: "OK", physicalId: "sg-mine" } },
|
|
31
|
+
});
|
|
32
|
+
// sg-mine is managed, so it is not ambient — it would otherwise be counted twice.
|
|
33
|
+
expect(Object.keys(found).sort()).toEqual(["sg-default", "sg-stray"]);
|
|
34
|
+
expect(found["sg-default"]).toMatchObject({ ambient: true, ownership: "foreign" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("carries the whole payload, leaving 'unused' to the graph", async () => {
|
|
38
|
+
// Deciding attachment in the reader would put a conclusion in the
|
|
39
|
+
// observation — the mistake liveInternetFacing made and #1271 undid.
|
|
40
|
+
spawnMock.mockResolvedValue(ok(GROUPS));
|
|
41
|
+
const found = await observeAwsAmbient({ kinds: ["AWS::EC2::SecurityGroup"], observed: {} });
|
|
42
|
+
expect(found["sg-default"].attributes).toMatchObject({ GroupName: "default", VpcId: "vpc-1" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("is bounded by the kinds the project declares", async () => {
|
|
46
|
+
// A project managing security groups is not made to enumerate the account.
|
|
47
|
+
const found = await observeAwsAmbient({ kinds: ["AWS::S3::Bucket"], observed: {} });
|
|
48
|
+
expect(found).toEqual({});
|
|
49
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("targets the stack's region", async () => {
|
|
53
|
+
spawnMock.mockResolvedValue(ok(GROUPS));
|
|
54
|
+
await observeAwsAmbient({ kinds: ["AWS::EC2::SecurityGroup"], observed: {}, region: "us-west-2" });
|
|
55
|
+
const argv = spawnMock.mock.calls[0][0] as string[];
|
|
56
|
+
expect(argv).toContain("--region");
|
|
57
|
+
expect(argv).toContain("us-west-2");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("a failed enumeration yields nothing rather than throwing", async () => {
|
|
61
|
+
spawnMock.mockResolvedValue({ stdout: "", stderr: "denied", exitCode: 255 });
|
|
62
|
+
await expect(
|
|
63
|
+
observeAwsAmbient({ kinds: ["AWS::EC2::SecurityGroup"], observed: {} }),
|
|
64
|
+
).resolves.toEqual({});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("declares which kinds it can enumerate", () => {
|
|
68
|
+
expect(canEnumerate("AWS::EC2::SecurityGroup")).toBe(true);
|
|
69
|
+
expect(canEnumerate("AWS::EC2::Instance")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/ambient.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resources that are simply *there* (#1278).
|
|
3
|
+
*
|
|
4
|
+
* `describeResources` answers "what do I manage" and `observeDependencies`
|
|
5
|
+
* answers "what do I rely on". Both resolve outward from the declared estate,
|
|
6
|
+
* so neither can see a resource nothing points at — and "which of my security
|
|
7
|
+
* groups are unused" is precisely a question about resources nothing points at.
|
|
8
|
+
*
|
|
9
|
+
* A state file cannot answer this at all: it knows what it created, and an
|
|
10
|
+
* unused resource is by definition not something it created and attached. A
|
|
11
|
+
* lexicon that can enumerate a kind can.
|
|
12
|
+
*
|
|
13
|
+
* Bounded by the kinds the project declares. A project managing security groups
|
|
14
|
+
* is asked about security groups, not about the account.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
18
|
+
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
19
|
+
|
|
20
|
+
/** Enumerable kinds, and how to list them. Extending this is the way to widen. */
|
|
21
|
+
const ENUMERABLE: Record<string, { argv: string[]; key: string; id: string; name?: string }> = {
|
|
22
|
+
"AWS::EC2::SecurityGroup": {
|
|
23
|
+
argv: ["ec2", "describe-security-groups"],
|
|
24
|
+
key: "SecurityGroups",
|
|
25
|
+
id: "GroupId",
|
|
26
|
+
name: "GroupName",
|
|
27
|
+
},
|
|
28
|
+
// The account's default VPC is the archetype: nothing declares it, nothing
|
|
29
|
+
// in the declared estate points at it, and "which instances are outside a
|
|
30
|
+
// default VPC" cannot be answered without it.
|
|
31
|
+
"AWS::EC2::VPC": {
|
|
32
|
+
argv: ["ec2", "describe-vpcs"],
|
|
33
|
+
key: "Vpcs",
|
|
34
|
+
id: "VpcId",
|
|
35
|
+
},
|
|
36
|
+
// Nobody writes a network interface: EC2 creates one per instance. But the
|
|
37
|
+
// ENI is where a security group is actually attached, so "which of my
|
|
38
|
+
// security groups are unused" is an ENI question and cannot be answered
|
|
39
|
+
// without them. Asked exactly that, an agent queried
|
|
40
|
+
// `kind:EC2::NetworkInterface`, got nothing, and rebuilt the attachment map
|
|
41
|
+
// by hand from twenty-nine raw provider calls.
|
|
42
|
+
"AWS::EC2::NetworkInterface": {
|
|
43
|
+
argv: ["ec2", "describe-network-interfaces"],
|
|
44
|
+
key: "NetworkInterfaces",
|
|
45
|
+
id: "NetworkInterfaceId",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Kinds a declared kind implies, though nobody writes them.
|
|
51
|
+
*
|
|
52
|
+
* The scan is bounded by what the project declares — a project managing
|
|
53
|
+
* security groups is asked about security groups, not about the account. That
|
|
54
|
+
* bound is right, and it excluded exactly the resource that answers the
|
|
55
|
+
* question: EC2 creates a network interface per instance, and the ENI is where
|
|
56
|
+
* a security group is actually attached. So an estate full of instances had no
|
|
57
|
+
* ENIs recorded, and "which of my security groups are unused" — definitionally
|
|
58
|
+
* an ENI question — could not be answered from the snapshot at all.
|
|
59
|
+
*
|
|
60
|
+
* Declaring an instance is declaring its network interface. This widens the
|
|
61
|
+
* bound by implication rather than abandoning it: still nothing about the
|
|
62
|
+
* account at large.
|
|
63
|
+
*/
|
|
64
|
+
const IMPLIED: Record<string, string[]> = {
|
|
65
|
+
"AWS::EC2::Instance": ["AWS::EC2::NetworkInterface"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The requested kinds, plus the ones they imply. */
|
|
69
|
+
export function withImplied(kinds: string[]): string[] {
|
|
70
|
+
return [...new Set(kinds.flatMap((k) => [k, ...(IMPLIED[k] ?? [])]))];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The kinds this reader can enumerate. Declared so a caller can mention
|
|
74
|
+
* `--ambient` without paying for a scan to discover it is relevant. */
|
|
75
|
+
export const AMBIENT_KINDS: string[] = Object.keys(ENUMERABLE);
|
|
76
|
+
|
|
77
|
+
/** True when this lexicon can enumerate the kind, so a caller can say what it skipped. */
|
|
78
|
+
export function canEnumerate(kind: string): boolean {
|
|
79
|
+
return kind in ENUMERABLE;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* List resources of the declared kinds that exist in the account but are
|
|
84
|
+
* neither managed nor already observed.
|
|
85
|
+
*
|
|
86
|
+
* Attachment is deliberately NOT decided here. Whether a security group is
|
|
87
|
+
* "unused" is a graph question — does anything reference it — and the graph
|
|
88
|
+
* answers it once the group is a node. Deciding it in the reader would put a
|
|
89
|
+
* conclusion in the observation, which is the mistake `liveInternetFacing` made
|
|
90
|
+
* and #1271 undid.
|
|
91
|
+
*/
|
|
92
|
+
export async function observeAwsAmbient(options: {
|
|
93
|
+
kinds: string[];
|
|
94
|
+
observed: Record<string, ResourceMetadata>;
|
|
95
|
+
region?: string;
|
|
96
|
+
}): Promise<Record<string, ResourceMetadata>> {
|
|
97
|
+
const kinds = withImplied(options.kinds).filter(canEnumerate);
|
|
98
|
+
if (kinds.length === 0) return {};
|
|
99
|
+
|
|
100
|
+
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
101
|
+
const rt = getRuntime();
|
|
102
|
+
const regionArgs = options.region ? ["--region", options.region] : [];
|
|
103
|
+
|
|
104
|
+
// Physical ids already accounted for, so a managed resource is never also
|
|
105
|
+
// reported as ambient.
|
|
106
|
+
const known = new Set(
|
|
107
|
+
Object.values(options.observed)
|
|
108
|
+
.map((m) => m.physicalId)
|
|
109
|
+
.filter((id): id is string => typeof id === "string"),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const ambient: Record<string, ResourceMetadata> = {};
|
|
113
|
+
for (const kind of kinds) {
|
|
114
|
+
const spec = ENUMERABLE[kind];
|
|
115
|
+
try {
|
|
116
|
+
const result = await rt.spawn(
|
|
117
|
+
applyAwsEndpointArgv(["aws", ...spec.argv, ...regionArgs, "--output", "json"], process.env.AWS_ENDPOINT_URL),
|
|
118
|
+
);
|
|
119
|
+
if (result.exitCode !== 0) continue;
|
|
120
|
+
const rows = (JSON.parse(result.stdout)[spec.key] ?? []) as Array<Record<string, unknown>>;
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
const id = row[spec.id];
|
|
123
|
+
if (typeof id !== "string" || known.has(id)) continue;
|
|
124
|
+
ambient[id] = {
|
|
125
|
+
type: kind,
|
|
126
|
+
status: "OBSERVED",
|
|
127
|
+
physicalId: id,
|
|
128
|
+
// The whole payload, so the graph can decide what "unused" means
|
|
129
|
+
// rather than the reader pre-deciding it.
|
|
130
|
+
attributes: row,
|
|
131
|
+
ownership: "foreign",
|
|
132
|
+
ambient: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Best-effort per kind: one unreadable kind does not sink the others, and
|
|
137
|
+
// the managed observation is already complete without any of this.
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Same stamps the managed path applies, so one query shape works across both
|
|
142
|
+
// (#1279). Defaults matter most here: a default VPC or security group is
|
|
143
|
+
// ambient by definition — nobody declared it.
|
|
144
|
+
const { stampRegion } = await import("./properties");
|
|
145
|
+
const { stampProviderDefaults } = await import("./defaults");
|
|
146
|
+
return stampProviderDefaults(stampRegion(ambient, options.region));
|
|
147
|
+
}
|
package/src/deep-observe.ts
CHANGED
|
@@ -246,6 +246,11 @@ export interface AwsDeepObserveOptions {
|
|
|
246
246
|
entityNames: string[];
|
|
247
247
|
entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
248
248
|
stack?: string;
|
|
249
|
+
/** Region this stack is deployed in (#1267). Same reason the thin path takes
|
|
250
|
+
* one (#1261): without it a multi-region estate reads every stack against the
|
|
251
|
+
* ambient region, the out-of-region ones come back empty, and a deep snapshot
|
|
252
|
+
* silently records no properties for them. */
|
|
253
|
+
region?: string;
|
|
249
254
|
owned?: boolean;
|
|
250
255
|
}
|
|
251
256
|
|
|
@@ -270,9 +275,12 @@ export async function observeResourcesDeepAws(
|
|
|
270
275
|
const stackName = options.stack ?? options.environment;
|
|
271
276
|
const endpoint = process.env.AWS_ENDPOINT_URL;
|
|
272
277
|
|
|
278
|
+
const regionArgs = options.region ? ["--region", options.region] : [];
|
|
279
|
+
|
|
273
280
|
const listResult = await rt.spawn(applyAwsEndpointArgv([
|
|
274
281
|
"aws", "cloudformation", "describe-stack-resources",
|
|
275
282
|
"--stack-name", stackName,
|
|
283
|
+
...regionArgs,
|
|
276
284
|
"--output", "json",
|
|
277
285
|
], endpoint));
|
|
278
286
|
|
|
@@ -334,6 +342,7 @@ export async function observeResourcesDeepAws(
|
|
|
334
342
|
"aws", "cloudcontrol", "get-resource",
|
|
335
343
|
"--type-name", type,
|
|
336
344
|
"--identifier", identifier,
|
|
345
|
+
...regionArgs,
|
|
337
346
|
"--output", "json",
|
|
338
347
|
], endpoint));
|
|
339
348
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { stampProviderDefaults, canDetectDefault, DEFAULT_AWARE_KINDS } from "./defaults";
|
|
3
|
+
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
4
|
+
|
|
5
|
+
const res = (type: string, attributes: Record<string, unknown>): ResourceMetadata => ({
|
|
6
|
+
type,
|
|
7
|
+
status: "OBSERVED",
|
|
8
|
+
physicalId: "x",
|
|
9
|
+
attributes,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const flag = (type: string, attributes: Record<string, unknown>) =>
|
|
13
|
+
stampProviderDefaults({ r: res(type, attributes) }).r.attributes?.providerDefault;
|
|
14
|
+
|
|
15
|
+
// Every account arrives with resources nobody wrote. Asked which security
|
|
16
|
+
// groups were unused, agents split three ways on the same correct set of four,
|
|
17
|
+
// because chant gave them no way to tell a VPC's default group from one someone
|
|
18
|
+
// created.
|
|
19
|
+
describe("provider defaults (#1278)", () => {
|
|
20
|
+
it("reads the marker AWS already sets, per kind", () => {
|
|
21
|
+
expect(flag("AWS::EC2::VPC", { IsDefault: true })).toBe(true);
|
|
22
|
+
expect(flag("AWS::EC2::Subnet", { DefaultForAz: true })).toBe(true);
|
|
23
|
+
expect(flag("AWS::EC2::NetworkAcl", { IsDefault: true })).toBe(true);
|
|
24
|
+
expect(flag("AWS::KMS::Key", { KeyManager: "AWS" })).toBe(true);
|
|
25
|
+
expect(flag("AWS::IAM::ManagedPolicy", { Arn: "arn:aws:iam::aws:policy/ReadOnlyAccess" })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does not mark what the account holder created", () => {
|
|
29
|
+
expect(flag("AWS::EC2::VPC", { IsDefault: false })).toBeUndefined();
|
|
30
|
+
expect(flag("AWS::EC2::Subnet", { DefaultForAz: false })).toBeUndefined();
|
|
31
|
+
expect(flag("AWS::KMS::Key", { KeyManager: "CUSTOMER" })).toBeUndefined();
|
|
32
|
+
expect(flag("AWS::IAM::ManagedPolicy", { Arn: "arn:aws:iam::000000000000:policy/Mine" })).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("treats the reserved group name as the marker it is", () => {
|
|
36
|
+
// AWS refuses `default` on create, so a group carrying it is the one AWS
|
|
37
|
+
// made with the VPC.
|
|
38
|
+
expect(flag("AWS::EC2::SecurityGroup", { GroupName: "default" })).toBe(true);
|
|
39
|
+
expect(flag("AWS::EC2::SecurityGroup", { GroupName: "default-web" })).toBeUndefined();
|
|
40
|
+
expect(flag("AWS::EC2::SecurityGroup", { GroupName: "webSecurityGroup" })).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("finds the main route table through its associations", () => {
|
|
44
|
+
expect(flag("AWS::EC2::RouteTable", { Associations: [{ Main: true }] })).toBe(true);
|
|
45
|
+
expect(flag("AWS::EC2::RouteTable", { Associations: [{ Main: false }, { Main: true }] })).toBe(true);
|
|
46
|
+
expect(flag("AWS::EC2::RouteTable", { Associations: [{ Main: false }] })).toBeUndefined();
|
|
47
|
+
expect(flag("AWS::EC2::RouteTable", {})).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("leaves a kind it cannot judge unmarked rather than guessing", () => {
|
|
51
|
+
// Absent means "not a default, or chant cannot tell". A blanket false would
|
|
52
|
+
// hide the difference.
|
|
53
|
+
expect(flag("AWS::EC2::Instance", { InstanceId: "i-1" })).toBeUndefined();
|
|
54
|
+
expect(canDetectDefault("AWS::EC2::Instance")).toBe(false);
|
|
55
|
+
expect(canDetectDefault("AWS::EC2::VPC")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("keeps every attribute it was given", () => {
|
|
59
|
+
const out = stampProviderDefaults({
|
|
60
|
+
r: res("AWS::EC2::SecurityGroup", { GroupName: "default", GroupId: "sg-1", region: "us-east-1" }),
|
|
61
|
+
}).r.attributes;
|
|
62
|
+
expect(out).toMatchObject({ GroupId: "sg-1", region: "us-east-1", providerDefault: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("survives a payload it cannot read", () => {
|
|
66
|
+
expect(flag("AWS::EC2::RouteTable", { Associations: "not-a-list" })).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reports the kinds it can judge, so a caller need not guess", () => {
|
|
70
|
+
expect(DEFAULT_AWARE_KINDS).toContain("AWS::EC2::SecurityGroup");
|
|
71
|
+
expect(DEFAULT_AWARE_KINDS.every(canDetectDefault)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/defaults.ts
ADDED
|
@@ -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
|
+
});
|