@intentius/chant-lexicon-terraform 0.124.45 → 0.124.46
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/__fixtures__/capabilities/chant.config.d.ts +26 -0
- package/dist/__fixtures__/capabilities/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/capabilities/chant.config.js +13 -0
- package/dist/__fixtures__/effective-topology/chant.config.d.ts +13 -0
- package/dist/__fixtures__/effective-topology/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/effective-topology/chant.config.js +9 -0
- package/dist/__fixtures__/kind-type/chant.config.d.ts +13 -0
- package/dist/__fixtures__/kind-type/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/kind-type/chant.config.js +9 -0
- package/dist/__fixtures__/search-roots/chant.config.d.ts +13 -0
- package/dist/__fixtures__/search-roots/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/search-roots/chant.config.js +9 -0
- package/dist/__fixtures__/sensitive-default/chant.config.d.ts +13 -0
- package/dist/__fixtures__/sensitive-default/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/sensitive-default/chant.config.js +9 -0
- package/dist/capabilities/adapter.d.ts +9 -0
- package/dist/capabilities/adapter.d.ts.map +1 -0
- package/dist/capabilities/adapter.js +25 -0
- package/dist/capabilities/mapping.json +166 -0
- package/dist/config.d.ts +52 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/describe-resources.d.ts +101 -8
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +395 -56
- package/dist/hcl/parse.d.ts +10 -0
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/hcl/parse.js +20 -2
- package/dist/hcl/redact.d.ts +26 -0
- package/dist/hcl/redact.d.ts.map +1 -0
- package/dist/hcl/redact.js +228 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/terraform.d.ts +109 -4
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/activities/terraform.js +148 -7
- package/dist/plan-observation.d.ts +153 -0
- package/dist/plan-observation.d.ts.map +1 -0
- package/dist/plan-observation.js +354 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +2 -0
- package/package.json +2 -2
- package/src/__fixtures__/ACCEPTANCE.md +53 -1
- package/src/__fixtures__/capabilities/catalog/main.tf +105 -0
- package/src/__fixtures__/capabilities/chant.config.ts +15 -0
- package/src/__fixtures__/capabilities/findings/main.tf +225 -0
- package/src/__fixtures__/capabilities/ledger/main.tf +265 -0
- package/src/__fixtures__/capabilities/package.json +1 -0
- package/src/__fixtures__/effective-topology/chant.config.ts +11 -0
- package/src/__fixtures__/effective-topology/net/main.tf +176 -0
- package/src/__fixtures__/effective-topology/package.json +6 -0
- package/src/__fixtures__/kind-type/app/main.tf +35 -0
- package/src/__fixtures__/kind-type/chant.config.ts +11 -0
- package/src/__fixtures__/kind-type/package.json +6 -0
- package/src/__fixtures__/live-plan-depth/README.md +59 -0
- package/src/__fixtures__/live-plan-depth/live-plan.json +110 -0
- package/src/__fixtures__/live-plan-depth/show-plan-sensitive.json +1 -0
- package/src/__fixtures__/live-plan-depth/show-plan.json +1 -0
- package/src/__fixtures__/plan-read/main.tf +46 -0
- package/src/__fixtures__/plan-read/next.tfvars +1 -0
- package/src/__fixtures__/plan-read-drift/README.md +33 -0
- package/src/__fixtures__/plan-read-drift/main.tf +63 -0
- package/src/__fixtures__/plan-read-drift/next.tfvars +1 -0
- package/src/__fixtures__/search-roots/chant.config.ts +11 -0
- package/src/__fixtures__/search-roots/net/main.tf +73 -0
- package/src/__fixtures__/search-roots/package.json +6 -0
- package/src/__fixtures__/sensitive-default/chant.config.ts +11 -0
- package/src/__fixtures__/sensitive-default/net/main.tf +38 -0
- package/src/__fixtures__/sensitive-default/package.json +6 -0
- package/src/__fixtures__/show-plan.json +663 -0
- package/src/capabilities/adapter.ts +32 -0
- package/src/capabilities/mapping.json +166 -0
- package/src/capabilities-workspace.e2e.test.ts +251 -0
- package/src/capabilities.test.ts +264 -0
- package/src/config.ts +26 -0
- package/src/describe-resources.live-plan.test.ts +302 -0
- package/src/describe-resources.live-root-unchanged.test.ts +126 -0
- package/src/describe-resources.plan.test.ts +437 -0
- package/src/describe-resources.ts +445 -52
- package/src/effective-topology.test.ts +134 -0
- package/src/hcl/parse.ts +30 -2
- package/src/hcl/redact.ts +181 -0
- package/src/live-plan-depth.acceptance.e2e.test.ts +193 -0
- package/src/op/activities/terraform.test.ts +118 -1
- package/src/op/activities/terraform.ts +213 -9
- package/src/plan-observation.ts +428 -0
- package/src/plan-read.acceptance.test.ts +184 -0
- package/src/plugin.ts +2 -0
- package/src/redact-ir.e2e.test.ts +79 -0
- package/src/redact.test.ts +111 -0
- package/src/search-build-roots.test.ts +125 -0
- package/src/search-count.test.ts +144 -0
- package/src/search-kind-type.test.ts +136 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability catalog over Terraform's AWS provider (#4375).
|
|
3
|
+
*
|
|
4
|
+
* `__fixtures__/capabilities/` holds three roots:
|
|
5
|
+
*
|
|
6
|
+
* ledger the estate chant#4361 measured the rule language on, whose
|
|
7
|
+
* answers were worked out by hand: identity reach 13 pairs,
|
|
8
|
+
* exfiltration 3, blast radius 15 from three starts.
|
|
9
|
+
* catalog a case that holds and one that doesn't for each capability the
|
|
10
|
+
* ledger leaves out (default VPC, unused things, public buckets,
|
|
11
|
+
* SecureString, encryption, an explicit Deny).
|
|
12
|
+
* findings the -findings layer of choudoufu's terralith-gen, the estate
|
|
13
|
+
* behold#912's step 6 asks about.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
19
|
+
import { buildGraphIr, type GraphIR } from "@intentius/chant/graph-ir";
|
|
20
|
+
import { CapabilityEngine } from "@intentius/chant/capabilities/matcher";
|
|
21
|
+
import { buildView, displayName } from "@intentius/chant/capabilities/view";
|
|
22
|
+
import { resolvePacks, shippedRules } from "@intentius/chant/capabilities/catalog";
|
|
23
|
+
import { runSearch } from "@intentius/chant/cli/handlers/search";
|
|
24
|
+
import { renderTerraformRoots } from "./hcl/roots";
|
|
25
|
+
import { terraformPlugin } from "./plugin";
|
|
26
|
+
import { terraformCapabilityPack } from "./capabilities/adapter";
|
|
27
|
+
|
|
28
|
+
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__", "capabilities");
|
|
29
|
+
|
|
30
|
+
let engine: CapabilityEngine;
|
|
31
|
+
let ir: GraphIR;
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
const rendered = await renderTerraformRoots({
|
|
35
|
+
projectRoot: FIXTURE,
|
|
36
|
+
roots: { ledger: { dir: "./ledger" }, catalog: { dir: "./catalog" }, findings: { dir: "./findings" } },
|
|
37
|
+
});
|
|
38
|
+
expect(rendered.warnings).toEqual([]);
|
|
39
|
+
ir = buildGraphIr(rendered.entities);
|
|
40
|
+
engine = new CapabilityEngine(buildView(ir, resolvePacks([terraformCapabilityPack])), shippedRules().rules);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const set = (name: string, args: unknown[] = [], root?: string) =>
|
|
44
|
+
[...engine.set(name, engine.bind(name, args as never)).keys()].filter((id) => !root || id.startsWith(`${root}/`)).map(displayName).sort();
|
|
45
|
+
const pairs = (name: string, args: unknown[] = [], root = "ledger") =>
|
|
46
|
+
[...engine.relation(name, engine.bind(name, args as never)).out]
|
|
47
|
+
.filter(([a]) => a.startsWith(`${root}/`))
|
|
48
|
+
.flatMap(([a, m]) => [...m.keys()].map((b) => `${displayName(a)} ${displayName(b)}`))
|
|
49
|
+
.sort();
|
|
50
|
+
|
|
51
|
+
describe("the ledger's three hand-checked answers (chant#4361)", () => {
|
|
52
|
+
it("identity reach: 13 (role, resource) pairs", () => {
|
|
53
|
+
expect(pairs("reaches")).toEqual([
|
|
54
|
+
"aws_iam_role.audit aws_dynamodb_table.orders",
|
|
55
|
+
"aws_iam_role.batch aws_dynamodb_table.orders",
|
|
56
|
+
"aws_iam_role.batch aws_iam_role.audit",
|
|
57
|
+
"aws_iam_role.deploy aws_s3_bucket.customer_data",
|
|
58
|
+
"aws_iam_role.deploy aws_secretsmanager_secret.db",
|
|
59
|
+
"aws_iam_role.reporter aws_s3_bucket.customer_data",
|
|
60
|
+
"aws_iam_role.web aws_iam_role.deploy",
|
|
61
|
+
"aws_iam_role.web aws_s3_bucket.assets",
|
|
62
|
+
"aws_iam_role.web aws_s3_bucket.customer_data",
|
|
63
|
+
"aws_iam_role.web aws_secretsmanager_secret.db",
|
|
64
|
+
"aws_iam_role.worker aws_dynamodb_table.orders",
|
|
65
|
+
"aws_iam_role.worker aws_s3_bucket.assets",
|
|
66
|
+
"aws_iam_role.worker aws_secretsmanager_secret.db",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("exfiltration: 3 (compute, store) pairs", () => {
|
|
71
|
+
expect(pairs("exfiltrates")).toEqual([
|
|
72
|
+
"aws_instance.web aws_s3_bucket.customer_data",
|
|
73
|
+
"aws_instance.web aws_secretsmanager_secret.db",
|
|
74
|
+
"aws_lambda_function.reporter aws_s3_bucket.customer_data",
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("blast radius: 15 members from webServer, reporter and batch", () => {
|
|
79
|
+
const from = (start: string) => pairs("blast").filter((p) => p.startsWith(`${start} `)).map((p) => p.split(" ")[1]);
|
|
80
|
+
expect(from("aws_instance.web")).toEqual([
|
|
81
|
+
"aws_db_instance.customers",
|
|
82
|
+
"aws_dynamodb_table.orders",
|
|
83
|
+
"aws_iam_role.deploy",
|
|
84
|
+
"aws_iam_role.web",
|
|
85
|
+
"aws_iam_role.worker",
|
|
86
|
+
"aws_instance.admin_box",
|
|
87
|
+
"aws_instance.worker",
|
|
88
|
+
"aws_s3_bucket.assets",
|
|
89
|
+
"aws_s3_bucket.customer_data",
|
|
90
|
+
"aws_secretsmanager_secret.db",
|
|
91
|
+
]);
|
|
92
|
+
expect(from("aws_lambda_function.reporter")).toEqual(["aws_iam_role.reporter", "aws_s3_bucket.customer_data"]);
|
|
93
|
+
expect(from("aws_lambda_function.batch")).toEqual(["aws_dynamodb_table.orders", "aws_iam_role.audit", "aws_iam_role.batch"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("the exfiltration path includes the trust statement that makes the assume-role hop real", () => {
|
|
97
|
+
const ev = engine.relation("exfiltrates").out.get("ledger/aws_instance.web")!.get("ledger/aws_s3_bucket.customer_data")!;
|
|
98
|
+
const visited = ev.hops.flatMap((h) => [h.from, h.to]).map(displayName);
|
|
99
|
+
expect(visited).toContain("aws_iam_role.deploy#assume_role_policy[0].Statement[0]");
|
|
100
|
+
expect(visited.at(-1)).toBe("aws_s3_bucket.customer_data");
|
|
101
|
+
expect(ev.because.map((b) => b.capability)).toEqual(["internet-reachable", "holds-secret"]);
|
|
102
|
+
expect(ev.because[0].evidence.because.map((b) => b.capability)).toEqual(["internet-facing", "open-ingress"]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("each capability on a fixture", () => {
|
|
107
|
+
it("internet-facing: a subnet routed to an internet gateway, by aws_route or by an inline route", () => {
|
|
108
|
+
expect(set("internet-facing", [], "ledger")).toEqual(["aws_instance.admin_box", "aws_instance.web"]);
|
|
109
|
+
expect(set("internet-facing", [], "findings")).toEqual(["aws_instance.bastion"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("open-ingress: a world-open rule reached through a launch template, filtered by port and protocol", () => {
|
|
113
|
+
expect(set("open-ingress", [], "ledger")).toEqual(["aws_instance.web"]);
|
|
114
|
+
expect(set("open-ingress", [22], "ledger")).toEqual(["aws_instance.web"]);
|
|
115
|
+
expect(set("open-ingress", [8080], "ledger")).toEqual([]);
|
|
116
|
+
expect(set("open-ingress", [22, "udp"], "ledger")).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("internet-reachable: facing and open, or a function URL with no auth", () => {
|
|
120
|
+
expect(set("internet-reachable", [], "ledger")).toEqual(["aws_instance.web", "aws_lambda_function.reporter"]);
|
|
121
|
+
expect(set("internet-reachable", [22], "findings")).toEqual(["aws_instance.bastion"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("attached-to-nothing: an unused group, an empty subnet, a policy no role holds", () => {
|
|
125
|
+
expect(set("attached-to-nothing", [], "catalog")).toEqual(["aws_iam_policy.orphan", "aws_security_group.unused", "aws_subnet.empty"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("in-default-vpc: an instance in a subnet of aws_default_vpc, and the subnet", () => {
|
|
129
|
+
expect(set("in-default-vpc", [], "catalog")).toEqual(["aws_instance.legacy", "aws_subnet.legacy"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("network: host to host through group-to-group ingress", () => {
|
|
133
|
+
expect(pairs("network")).toEqual([
|
|
134
|
+
"aws_instance.admin_box aws_db_instance.customers",
|
|
135
|
+
"aws_instance.web aws_instance.admin_box",
|
|
136
|
+
"aws_instance.web aws_instance.worker",
|
|
137
|
+
"aws_instance.worker aws_db_instance.customers",
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("stores-data and holds-secret: restricted by tag (through a local), a secret, a SecureString", () => {
|
|
142
|
+
expect(set("stores-data", [], "ledger")).toEqual([
|
|
143
|
+
"aws_db_instance.customers",
|
|
144
|
+
"aws_dynamodb_table.orders",
|
|
145
|
+
"aws_s3_bucket.assets",
|
|
146
|
+
"aws_s3_bucket.customer_data",
|
|
147
|
+
"aws_secretsmanager_secret.db",
|
|
148
|
+
]);
|
|
149
|
+
expect(set("holds-secret", [], "ledger")).toEqual(["aws_db_instance.customers", "aws_s3_bucket.customer_data", "aws_secretsmanager_secret.db"]);
|
|
150
|
+
expect(set("holds-secret", [], "catalog")).toEqual(["aws_ssm_parameter.token"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("encrypted-at-rest: by attribute for a database or file system, always for a bucket", () => {
|
|
154
|
+
const enc = set("encrypted-at-rest", [], "catalog");
|
|
155
|
+
expect(enc).toEqual(["aws_efs_file_system.shared", "aws_s3_bucket.blocked", "aws_s3_bucket.open"]);
|
|
156
|
+
expect(enc).not.toContain("aws_db_instance.plain");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("publicly-readable: principal * with no public-access block in the way", () => {
|
|
160
|
+
expect(set("publicly-readable", [], "catalog")).toEqual(["aws_s3_bucket.open"]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("runs-as: through an instance profile, a launch template's profile, a Lambda role", () => {
|
|
164
|
+
expect(pairs("runs-as")).toEqual([
|
|
165
|
+
"aws_instance.admin_box aws_iam_role.worker",
|
|
166
|
+
"aws_instance.web aws_iam_role.web",
|
|
167
|
+
"aws_instance.worker aws_iam_role.worker",
|
|
168
|
+
"aws_lambda_function.batch aws_iam_role.batch",
|
|
169
|
+
"aws_lambda_function.reporter aws_iam_role.reporter",
|
|
170
|
+
]);
|
|
171
|
+
expect(pairs("runs-as", [], "findings")).toEqual(["aws_instance.bastion aws_iam_role.app_data"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("can-assume: by reference with a matching trust, and by ARN string naming a declared role", () => {
|
|
175
|
+
expect(pairs("can-assume")).toEqual(["aws_iam_role.batch aws_iam_role.audit", "aws_iam_role.web aws_iam_role.deploy"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("grants: an action argument narrows it, and an explicit Deny on the same pair cancels it", () => {
|
|
179
|
+
expect(pairs("grants", ["sts:AssumeRole"])).toEqual(["aws_iam_role.batch aws_iam_role.audit", "aws_iam_role.web aws_iam_role.deploy"]);
|
|
180
|
+
expect(pairs("grants", ["s3:GetObject"], "catalog")).toEqual(["aws_iam_role.denied aws_s3_bucket.blocked"]);
|
|
181
|
+
expect(pairs("grants", [], "catalog")).toEqual(["aws_iam_role.denied aws_s3_bucket.blocked"]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("behold#912 step 6: what the findings layer's role can reach", () => {
|
|
186
|
+
it("reaches the DynamoDB table and no other store, and leaves the wildcard roles to the not-evaluated note", () => {
|
|
187
|
+
const reached = [...engine.relation("reaches").out.get("findings/aws_iam_role.app_data")!.keys()].map(displayName);
|
|
188
|
+
expect(reached).toEqual(["aws_dynamodb_table.sessions"]);
|
|
189
|
+
expect(engine.relation("reaches").out.get("findings/aws_iam_role.team_0002_role")).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("chant search with capabilities", () => {
|
|
194
|
+
afterEach(() => vi.restoreAllMocks());
|
|
195
|
+
|
|
196
|
+
async function search(args: Record<string, unknown>): Promise<{ exit: number; out: string[]; err: string[] }> {
|
|
197
|
+
const out: string[] = [];
|
|
198
|
+
const err: string[] = [];
|
|
199
|
+
vi.spyOn(console, "log").mockImplementation((line: unknown) => void out.push(String(line)));
|
|
200
|
+
vi.spyOn(console, "error").mockImplementation((line: unknown) => void err.push(String(line)));
|
|
201
|
+
const cwd = process.cwd();
|
|
202
|
+
process.chdir(FIXTURE);
|
|
203
|
+
try {
|
|
204
|
+
const exit = await runSearch({ args: args as never, plugins: [terraformPlugin], serializers: [] });
|
|
205
|
+
return { exit, out, err };
|
|
206
|
+
} finally {
|
|
207
|
+
process.chdir(cwd);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
it("step 6: the store the role reaches, with what the rule did not evaluate", async () => {
|
|
212
|
+
const { exit, out } = await search({ path: "cap:stores-data <-[cap=reaches]aws_iam_role.app_data" });
|
|
213
|
+
expect(exit).toBe(0);
|
|
214
|
+
expect(out[0]).toBe("findings/aws_dynamodb_table.sessions aws_dynamodb_table");
|
|
215
|
+
expect(out[1]).toBe("— declared only · no observation · physical ids unavailable · stores-data, reaches derived from declared references");
|
|
216
|
+
expect(out[2]).toMatch(/^— reaches does not evaluate: IAM Condition keys; Resource "\*" \(2 statements here\);/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("--explain prints each match's path in #4359's hop format, and the sets it relied on", async () => {
|
|
220
|
+
const { out } = await search({ path: "kind:aws_instance cap:internet-reachable(22)", explain: true });
|
|
221
|
+
const lines = out.filter((l) => l.includes("bastion"));
|
|
222
|
+
expect(lines).toContain(
|
|
223
|
+
" path findings/aws_instance.bastion cap:internet-reachable(22): (the node itself)",
|
|
224
|
+
);
|
|
225
|
+
expect(lines).toContain(
|
|
226
|
+
" internet-facing findings/aws_instance.bastion: findings/aws_instance.bastion.subnet_id -> findings/aws_subnet.public.id; findings/aws_subnet.public.id <- findings/aws_route_table_association.public.subnet_id; findings/aws_route_table_association.public.route_table_id -> findings/aws_route_table.public.id; findings/aws_route_table.public.route -> findings/aws_route_table.public#route[0]; findings/aws_route_table.public#route[0].gateway_id -> findings/aws_internet_gateway.main.id",
|
|
227
|
+
);
|
|
228
|
+
expect(lines).toContain(
|
|
229
|
+
" open-ingress findings/aws_instance.bastion: findings/aws_instance.bastion.launch_template -> findings/aws_launch_template.bastion.id; findings/aws_launch_template.bastion.vpc_security_group_ids -> findings/aws_security_group.bastion.id; findings/aws_security_group.bastion.id <- findings/aws_vpc_security_group_ingress_rule.bastion_ssh.security_group_id",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("--explain prints an edge capability's path through the policy statement", async () => {
|
|
234
|
+
const { out } = await search({ path: "aws_iam_role.app_data ->[cap=reaches]cap:stores-data", explain: true });
|
|
235
|
+
expect(out).toContain(
|
|
236
|
+
" path findings/aws_iam_role.app_data ->[cap=reaches]cap:stores-data: findings/aws_iam_role.app_data.name <- findings/aws_iam_role_policy_attachment.app_data.role; findings/aws_iam_role_policy_attachment.app_data.policy_arn -> findings/aws_iam_policy.app_data.arn; findings/aws_iam_policy.app_data.policy -> findings/aws_iam_policy.app_data#policy[0].Statement[0]; findings/aws_iam_policy.app_data#policy[0].Statement[0].Resource[0] -> findings/aws_dynamodb_table.sessions.arn",
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("--json carries the path, each hop's capability edge, and the not-evaluated list", async () => {
|
|
241
|
+
const { out } = await search({ path: "cap:exfiltrates", json: true });
|
|
242
|
+
const doc = JSON.parse(out[0]);
|
|
243
|
+
expect(doc.matches.map((m: { id: string }) => m.id)).toEqual(["ledger/aws_instance.web", "ledger/aws_lambda_function.reporter"]);
|
|
244
|
+
const path = doc.matches[0].paths[0];
|
|
245
|
+
expect(path.term).toBe("cap:exfiltrates");
|
|
246
|
+
expect(path.hops[0]).toEqual({ from: "ledger/aws_instance.web", fromAttr: "iam_instance_profile", to: "ledger/aws_iam_instance_profile.web", toAttr: "name", edge: "profile" });
|
|
247
|
+
expect(path.because.map((b: { capability: string }) => b.capability)).toContain("internet-facing");
|
|
248
|
+
expect(doc.capabilities).toEqual([
|
|
249
|
+
expect.objectContaining({ name: "exfiltrates", source: "declared", notEvaluated: expect.arrayContaining(["IAM Condition keys"]) }),
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("refuses an unknown capability, a set used as an edge, and the wrong arguments", async () => {
|
|
254
|
+
let r = await search({ path: "cap:reachable" });
|
|
255
|
+
expect(r.exit).toBe(1);
|
|
256
|
+
expect(r.err.join("\n")).toMatch(/no capability "reachable"/);
|
|
257
|
+
r = await search({ path: "->[cap=stores-data]kind:aws_s3_bucket" });
|
|
258
|
+
expect(r.exit).toBe(1);
|
|
259
|
+
expect(r.err.join("\n")).toMatch(/"stores-data" is a set, not an edge/);
|
|
260
|
+
r = await search({ path: "cap:open-ingress(22,tcp,3)" });
|
|
261
|
+
expect(r.exit).toBe(1);
|
|
262
|
+
expect(r.err.join("\n")).toMatch(/open-ingress takes at most 2 \(port, proto\)/);
|
|
263
|
+
});
|
|
264
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -48,6 +48,14 @@ export type TerraformDeleteMode = z.infer<typeof terraformDeleteModeSchema>;
|
|
|
48
48
|
* strict, and a root entry is exactly where a typo (`varfiles`) would
|
|
49
49
|
* otherwise land silently.
|
|
50
50
|
*/
|
|
51
|
+
/**
|
|
52
|
+
* How deep a root is observed (#4358): `"state"` reads state, `"plan"`
|
|
53
|
+
* reads a saved plan. See {@link TerraformRootConfig.observe}.
|
|
54
|
+
*/
|
|
55
|
+
export const terraformObserveDepthSchema = z.enum(["state", "plan"]);
|
|
56
|
+
|
|
57
|
+
export type TerraformObserveDepth = z.infer<typeof terraformObserveDepthSchema>;
|
|
58
|
+
|
|
51
59
|
export const terraformRootSchema = z.strictObject({
|
|
52
60
|
/** Root module directory, relative to the project root (where `chant.config.*` lives). */
|
|
53
61
|
dir: z.string(),
|
|
@@ -85,6 +93,22 @@ export const terraformRootSchema = z.strictObject({
|
|
|
85
93
|
estate: z.string().optional(),
|
|
86
94
|
/** This root's declared delete mode. See {@link terraformDeleteModeSchema}. */
|
|
87
95
|
delete: terraformDeleteModeSchema.optional(),
|
|
96
|
+
/**
|
|
97
|
+
* How deep `describeResources` reads this root (#4358). `"state"`, the
|
|
98
|
+
* default, is `terraform show -json` over state: what the last apply
|
|
99
|
+
* recorded. `"plan"` is a saved plan, `plan -lock=false -out=<tmp>` then
|
|
100
|
+
* `show -json`: what the next apply would do, with replace reasons,
|
|
101
|
+
* values unknown until apply and drift. It needs provider credentials and
|
|
102
|
+
* an initialised root, and falls back to the state read, then to declared
|
|
103
|
+
* only, saying which read answered. A live choudoufu root keeps its
|
|
104
|
+
* `live-plan -json` read for ownership at either depth; at `"plan"` the
|
|
105
|
+
* plan read runs beside it and its attributes go on the blocks `live-plan`
|
|
106
|
+
* bound (#4371).
|
|
107
|
+
*
|
|
108
|
+
* The `CHANT_TERRAFORM_OBSERVE` environment variable overrides it for
|
|
109
|
+
* every root, for one run.
|
|
110
|
+
*/
|
|
111
|
+
observe: terraformObserveDepthSchema.optional(),
|
|
88
112
|
});
|
|
89
113
|
|
|
90
114
|
export const terraformConfigSchema = z.strictObject({
|
|
@@ -119,6 +143,8 @@ export const terraformConfigSchema = z.strictObject({
|
|
|
119
143
|
* project does not own.
|
|
120
144
|
*/
|
|
121
145
|
moduleRoot: z.string().optional(),
|
|
146
|
+
/** The default {@link TerraformRootConfig.observe} for every root that sets none (#4358). */
|
|
147
|
+
observe: terraformObserveDepthSchema.optional(),
|
|
122
148
|
/** Named root modules. The name is the entity-key prefix, so keep it stable. */
|
|
123
149
|
roots: z.record(z.string(), terraformRootSchema),
|
|
124
150
|
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan depth on a live choudoufu root (#4371): `live-plan -json` for
|
|
3
|
+
* ownership, the plan read for the attributes, merged per block.
|
|
4
|
+
*
|
|
5
|
+
* The declared side is `src/__fixtures__/live-estate/`, rendered as
|
|
6
|
+
* `buildRoots()` renders it. The read side is
|
|
7
|
+
* `src/__fixtures__/live-plan-depth/`: a `live-plan -json` document and two
|
|
8
|
+
* `show -json <planfile>` documents recorded from that configuration by the
|
|
9
|
+
* choudoufu v0.30.0 release binary against the pinned floci emulator, with
|
|
10
|
+
* drift made out of band (`live-plan-depth/README.md` is the log).
|
|
11
|
+
*
|
|
12
|
+
* Nothing here runs choudoufu: the activities are injected. The real binary
|
|
13
|
+
* is `live-plan-depth.acceptance.test.ts`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
import { normalizeObservation } from "@intentius/chant/observation";
|
|
22
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
23
|
+
import { buildLiveGraphIr } from "@intentius/chant/graph-ir";
|
|
24
|
+
import { __searchInternals } from "@intentius/chant/cli/handlers/search";
|
|
25
|
+
import { describeResources, livePlanDocumentFile, TERRAFORM_LIVE_PLAN_DIR_ENV, type TerraformReadDeps } from "./describe-resources";
|
|
26
|
+
import { TerraformPlanReadError } from "./op/activities/terraform";
|
|
27
|
+
import { PLAN_ATTRIBUTE_NAMES, SENSITIVE_VALUE } from "./plan-observation";
|
|
28
|
+
import { renderTerraformRoots } from "./hcl/roots";
|
|
29
|
+
|
|
30
|
+
const fixtures = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
|
|
31
|
+
const RECORDED = join(fixtures, "live-plan-depth");
|
|
32
|
+
const ROOT_DIR = join(fixtures, "live-estate");
|
|
33
|
+
const LIVE_PLAN: unknown = JSON.parse(readFileSync(join(RECORDED, "live-plan.json"), "utf-8"));
|
|
34
|
+
const PLAN: unknown = JSON.parse(readFileSync(join(RECORDED, "show-plan.json"), "utf-8"));
|
|
35
|
+
const PLAN_SENSITIVE: unknown = JSON.parse(readFileSync(join(RECORDED, "show-plan-sensitive.json"), "utf-8"));
|
|
36
|
+
const ESTATE = "stateless-e2e-block";
|
|
37
|
+
|
|
38
|
+
type Entities = Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
39
|
+
|
|
40
|
+
/** The fixture root rendered under `binary`: `"choudoufu"` makes it live, `"terraform"` stock. */
|
|
41
|
+
async function declaredEntities(binary: "choudoufu" | "terraform"): Promise<Entities> {
|
|
42
|
+
const { entities } = await renderTerraformRoots({
|
|
43
|
+
projectRoot: fixtures,
|
|
44
|
+
roots: { estate: { dir: "./live-estate" } },
|
|
45
|
+
binary,
|
|
46
|
+
});
|
|
47
|
+
const out: Entities = new Map();
|
|
48
|
+
for (const [key, entity] of entities) {
|
|
49
|
+
out.set(key, { entityType: entity.entityType, props: (entity as Declarable & { props: Record<string, unknown> }).props });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function options(binary: "choudoufu" | "terraform" = "choudoufu", depth: "plan" | "state" = "plan") {
|
|
55
|
+
const entities = await declaredEntities(binary);
|
|
56
|
+
return { environment: "prod", buildOutput: "", entityNames: [...entities.keys()].sort(), entities, depth };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const never = ((args: { root: string }) => {
|
|
60
|
+
throw new Error(`unexpected activity for root "${args.root}"`);
|
|
61
|
+
}) as never;
|
|
62
|
+
|
|
63
|
+
const COMMAND = "choudoufu plan -input=false -lock=false -out=/tmp/chant-terraform-plan-x/plan.tfplan";
|
|
64
|
+
|
|
65
|
+
function deps(overrides?: Partial<TerraformReadDeps>, plan: unknown = PLAN): TerraformReadDeps {
|
|
66
|
+
return {
|
|
67
|
+
init: never,
|
|
68
|
+
show: never,
|
|
69
|
+
liveLs: never,
|
|
70
|
+
livePlan: (async () => ({ json: LIVE_PLAN, dir: ROOT_DIR, estate: ESTATE })) as unknown as TerraformReadDeps["livePlan"],
|
|
71
|
+
planRead: (async () => ({ json: plan, dir: ROOT_DIR, planDir: "/tmp/chant-terraform-plan-x", command: COMMAND })) as TerraformReadDeps["planRead"],
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const planOnly = (attributes: Record<string, unknown> | undefined): Record<string, unknown> =>
|
|
77
|
+
Object.fromEntries(Object.entries(attributes ?? {}).filter(([k]) => k.startsWith("plan")));
|
|
78
|
+
|
|
79
|
+
const BOUND = [
|
|
80
|
+
"estate/aws_cloudwatch_log_group.app",
|
|
81
|
+
"estate/aws_eip.pool",
|
|
82
|
+
"estate/aws_security_group.main",
|
|
83
|
+
"estate/aws_subnet.app",
|
|
84
|
+
"estate/aws_vpc.main",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
describe("a live root at the plan depth carries the plan's attributes on bound blocks (#4371)", () => {
|
|
88
|
+
it("drift made out of band is the planned update, live value before, declared value after", async () => {
|
|
89
|
+
const result = normalizeObservation(await describeResources(await options(), deps()));
|
|
90
|
+
const attrs = (name: string) => result.resources[`estate/${name}`]!.attributes as Record<string, unknown>;
|
|
91
|
+
|
|
92
|
+
expect(attrs("aws_cloudwatch_log_group.app")).toMatchObject({
|
|
93
|
+
planAction: "update",
|
|
94
|
+
planChanges: [{ path: "retention_in_days", before: 3, after: 1 }],
|
|
95
|
+
});
|
|
96
|
+
expect(attrs("aws_vpc.main").planAction).toBe("update");
|
|
97
|
+
expect(attrs("aws_vpc.main").planChanges).toEqual(
|
|
98
|
+
expect.arrayContaining([
|
|
99
|
+
{ path: "tags.drifted", before: "yes", after: null },
|
|
100
|
+
{ path: "tags_all.drifted", before: "yes", after: null },
|
|
101
|
+
]),
|
|
102
|
+
);
|
|
103
|
+
expect(attrs("aws_eip.pool")).toMatchObject({
|
|
104
|
+
planAction: "no-op",
|
|
105
|
+
planActions: { "aws_eip.pool[0]": ["no-op"], "aws_eip.pool[1]": ["no-op"] },
|
|
106
|
+
});
|
|
107
|
+
// choudoufu rebuilds prior state from the account, so there is no
|
|
108
|
+
// recorded state for `resource_drift` to compare against.
|
|
109
|
+
for (const name of BOUND) expect(result.resources[name]!.attributes).not.toHaveProperty("planDrift");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("ownership still comes from live-plan's markers, unchanged by the plan read", async () => {
|
|
113
|
+
const atPlan = normalizeObservation(await describeResources(await options(), deps()));
|
|
114
|
+
const atDefault = normalizeObservation(await describeResources(await options("choudoufu", "state"), deps({ planRead: never })));
|
|
115
|
+
expect(Object.keys(atPlan.resources).sort()).toEqual(Object.keys(atDefault.resources).sort());
|
|
116
|
+
expect(Object.keys(atPlan.unobserved).sort()).toEqual(Object.keys(atDefault.unobserved).sort());
|
|
117
|
+
for (const [name, meta] of Object.entries(atDefault.resources)) {
|
|
118
|
+
const merged = atPlan.resources[name]!;
|
|
119
|
+
expect({ ...merged, attributes: undefined }).toEqual({ ...meta, attributes: undefined });
|
|
120
|
+
const rest = Object.fromEntries(Object.entries(merged.attributes ?? {}).filter(([k]) => !k.startsWith("plan")));
|
|
121
|
+
expect(rest, name).toEqual(meta.attributes);
|
|
122
|
+
}
|
|
123
|
+
expect(atPlan.resources["estate/aws_vpc.main"]!.marker).toEqual({ stack: ESTATE });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("the plan attributes on a bound block are the ones a stock root gets from the same plan", async () => {
|
|
127
|
+
const live = normalizeObservation(await describeResources(await options("choudoufu"), deps()));
|
|
128
|
+
const stock = normalizeObservation(await describeResources(await options("terraform"), deps()));
|
|
129
|
+
expect(new Set(Object.values(stock.sources))).toEqual(new Set(["plan"]));
|
|
130
|
+
for (const name of BOUND) {
|
|
131
|
+
const attrs = planOnly(live.resources[name]!.attributes);
|
|
132
|
+
expect(Object.keys(attrs).length, name).toBeGreaterThan(0);
|
|
133
|
+
expect(attrs, name).toEqual(planOnly(stock.resources[name]!.attributes));
|
|
134
|
+
for (const key of Object.keys(attrs)) expect((PLAN_ATTRIBUTE_NAMES as readonly string[]).includes(key), key).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a foreign or adoptable block gets no plan attributes, and a planned create stays absent", async () => {
|
|
139
|
+
const result = normalizeObservation(await describeResources(await options(), deps()));
|
|
140
|
+
const held = result.resources["estate/aws_cloudwatch_log_group.held_elsewhere"]!;
|
|
141
|
+
const adoptable = result.resources["estate/aws_cloudwatch_log_group.adoptable"]!;
|
|
142
|
+
expect(held.ownership).toBe("foreign");
|
|
143
|
+
expect(adoptable.ownership).toBe("unknown");
|
|
144
|
+
expect(planOnly(held.attributes)).toEqual({});
|
|
145
|
+
expect(planOnly(adoptable.attributes)).toEqual({});
|
|
146
|
+
expect(result.resources["estate/aws_cloudwatch_log_group.never_applied"]).toBeUndefined();
|
|
147
|
+
expect(result.unobserved["estate/aws_cloudwatch_log_group.never_applied"]).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("chant search matches the plan attributes on a live root", async () => {
|
|
151
|
+
const result = normalizeObservation(await describeResources(await options(), deps()));
|
|
152
|
+
const ir = buildLiveGraphIr([{ lexicon: "terraform", resources: result.resources }]);
|
|
153
|
+
const { parseQuery, matchTerm } = __searchInternals;
|
|
154
|
+
const search = (query: string) =>
|
|
155
|
+
ir.nodes
|
|
156
|
+
.filter((n) => parseQuery(query).every((t) => matchTerm(n, t, ir)))
|
|
157
|
+
.map((n) => n.id)
|
|
158
|
+
.sort();
|
|
159
|
+
expect(search("attr:planAction=update")).toEqual(["estate/aws_cloudwatch_log_group.app", "estate/aws_vpc.main"]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("which reads answered is on the wire: \"live+plan\" (#4371)", () => {
|
|
164
|
+
it("every entity of the root reads \"live+plan\", and the note names both reads", async () => {
|
|
165
|
+
const opts = await options();
|
|
166
|
+
const result = normalizeObservation(await describeResources(opts, deps()));
|
|
167
|
+
expect(Object.keys(result.sources).sort()).toEqual([...opts.entityNames].sort());
|
|
168
|
+
expect(new Set(Object.values(result.sources))).toEqual(new Set(["live+plan"]));
|
|
169
|
+
expect(result.rootReads).toEqual({ "terraform.roots.estate": { depth: "live+plan" } });
|
|
170
|
+
expect(result.notes.join("\n")).toContain(
|
|
171
|
+
"terraform.roots.estate: the plan read answered (`choudoufu plan -input=false -lock=false -out=<tmp>/plan.tfplan`), beside `live-plan -json`, which answered presence and ownership",
|
|
172
|
+
);
|
|
173
|
+
expect(result.queried["estate/aws_vpc.main"]).toMatch(/^choudoufu live-plan -json .*; choudoufu plan -input=false -lock=false .* \+ show -json <planfile>$/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("the default depth on a live root never calls the plan read and still reads \"live\"", async () => {
|
|
177
|
+
const result = normalizeObservation(await describeResources(await options("choudoufu", "state"), deps({ planRead: never })));
|
|
178
|
+
expect(new Set(Object.values(result.sources))).toEqual(new Set(["live"]));
|
|
179
|
+
expect(planOnly(result.resources["estate/aws_vpc.main"]!.attributes)).toEqual({});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("a live root falls back to live-plan alone, and says so (#4371)", () => {
|
|
184
|
+
it("a plan read without provider credentials leaves live-plan's answer, reading \"live\"", async () => {
|
|
185
|
+
const planRead = (async () => {
|
|
186
|
+
throw new TerraformPlanReadError("plan", "choudoufu plan failed in /x: Error: No valid credential sources found", ROOT_DIR);
|
|
187
|
+
}) as TerraformReadDeps["planRead"];
|
|
188
|
+
const result = normalizeObservation(await describeResources(await options(), deps({ planRead })));
|
|
189
|
+
const vpc = result.resources["estate/aws_vpc.main"]!;
|
|
190
|
+
expect(vpc.ownership).toBe("owned");
|
|
191
|
+
expect(planOnly(vpc.attributes)).toEqual({});
|
|
192
|
+
expect(new Set(Object.values(result.sources))).toEqual(new Set(["live"]));
|
|
193
|
+
expect(result.notes.join("\n")).toMatch(
|
|
194
|
+
/terraform\.roots\.estate: the plan read failed \(no provider credentials: .*No valid credential sources found\), so `live-plan -json` alone answered; plan attributes are absent/,
|
|
195
|
+
);
|
|
196
|
+
expect(result.rootReads).toEqual({ "terraform.roots.estate": { depth: "live+plan", fellBackTo: "live", reason: "no-credentials" } });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("a root that needs init says so, and live-plan's answer stands", async () => {
|
|
200
|
+
const planRead = (async () => {
|
|
201
|
+
throw new TerraformPlanReadError("not-initialised", "terraform.roots.estate (/x) needs init (Error: Required plugins are not installed)", ROOT_DIR);
|
|
202
|
+
}) as TerraformReadDeps["planRead"];
|
|
203
|
+
const result = normalizeObservation(await describeResources(await options(), deps({ planRead })));
|
|
204
|
+
expect(result.resources["estate/aws_vpc.main"]!.ownership).toBe("owned");
|
|
205
|
+
expect(result.notes.join("\n")).toMatch(/the plan read failed \(the root needs init, which chant never runs: /);
|
|
206
|
+
expect(result.rootReads).toEqual({ "terraform.roots.estate": { depth: "live+plan", fellBackTo: "live", reason: "needs-init" } });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("a plan read that cannot reach the backend, or fails otherwise, gets its own reason", async () => {
|
|
210
|
+
const failing = (kind: "backend" | "plan", message: string) =>
|
|
211
|
+
(async () => {
|
|
212
|
+
throw new TerraformPlanReadError(kind, message, ROOT_DIR);
|
|
213
|
+
}) as TerraformReadDeps["planRead"];
|
|
214
|
+
const backend = normalizeObservation(await describeResources(await options(), deps({ planRead: failing("backend", "Error: Failed to get existing workspaces") })));
|
|
215
|
+
expect(backend.rootReads).toEqual({ "terraform.roots.estate": { depth: "live+plan", fellBackTo: "live", reason: "backend-unreachable" } });
|
|
216
|
+
const plain = normalizeObservation(await describeResources(await options(), deps({ planRead: failing("plan", "Error: Invalid count argument") })));
|
|
217
|
+
expect(plain.rootReads).toEqual({ "terraform.roots.estate": { depth: "live+plan", fellBackTo: "live", reason: "plan-failed" } });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("a failed live-plan leaves the root not observed and never tries the plan read", async () => {
|
|
221
|
+
const livePlan = (async () => {
|
|
222
|
+
throw new Error("choudoufu live-plan failed in /x (exit 1)\nError: Provider unavailable for marker discovery");
|
|
223
|
+
}) as unknown as TerraformReadDeps["livePlan"];
|
|
224
|
+
const result = normalizeObservation(await describeResources(await options(), deps({ livePlan, planRead: never })));
|
|
225
|
+
expect(Object.keys(result.resources)).toEqual([]);
|
|
226
|
+
expect(result.unobserved["estate/aws_vpc.main"]!.reason).toBe("read-failed");
|
|
227
|
+
expect(new Set(Object.values(result.sources))).toEqual(new Set(["live"]));
|
|
228
|
+
expect(result.notes.join("\n")).toMatch(/terraform\.roots\.estate: no read answered \(`live-plan -json` failed, so the plan read was not tried\)/);
|
|
229
|
+
expect(result.rootReads).toBeUndefined();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("sensitive values on a live root (#4371)", () => {
|
|
234
|
+
it("a secret is redacted on both sides, though choudoufu flags only the declared side", async () => {
|
|
235
|
+
// Recorded: the tag was applied as "hunter2", then the variable changed to
|
|
236
|
+
// "hunter3". The live value in `before` is the old secret, and the
|
|
237
|
+
// document flags `after` only.
|
|
238
|
+
const raw = readFileSync(join(RECORDED, "show-plan-sensitive.json"), "utf-8");
|
|
239
|
+
expect(raw).toContain("hunter2");
|
|
240
|
+
expect(raw).toContain("hunter3");
|
|
241
|
+
const vpcChange = (PLAN_SENSITIVE as { resource_changes: Array<{ address: string; change: Record<string, unknown> }> }).resource_changes.find(
|
|
242
|
+
(r) => r.address === "aws_vpc.main",
|
|
243
|
+
)!.change;
|
|
244
|
+
expect(vpcChange.before_sensitive).toEqual({ tags: {}, tags_all: {} });
|
|
245
|
+
expect(vpcChange.after_sensitive).toEqual({ tags: { token: true }, tags_all: {} });
|
|
246
|
+
|
|
247
|
+
const result = await describeResources(await options(), deps(undefined, PLAN_SENSITIVE));
|
|
248
|
+
expect(JSON.stringify(result)).not.toMatch(/hunter[23]/);
|
|
249
|
+
const vpc = normalizeObservation(result).resources["estate/aws_vpc.main"]!.attributes as Record<string, unknown>;
|
|
250
|
+
expect(vpc.planChanges).toEqual(
|
|
251
|
+
expect.arrayContaining([
|
|
252
|
+
{ path: "tags.token", before: SENSITIVE_VALUE, after: SENSITIVE_VALUE },
|
|
253
|
+
// Unflagged on both sides; redacted because it equals a flagged value.
|
|
254
|
+
{ path: "tags_all.token", before: SENSITIVE_VALUE, after: SENSITIVE_VALUE },
|
|
255
|
+
]),
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("the live-plan document, handed to a caller that asks (behold#924)", () => {
|
|
261
|
+
const withEnv = async <T>(value: string | undefined, fn: () => Promise<T>): Promise<T> => {
|
|
262
|
+
const before = process.env[TERRAFORM_LIVE_PLAN_DIR_ENV];
|
|
263
|
+
if (value === undefined) delete process.env[TERRAFORM_LIVE_PLAN_DIR_ENV];
|
|
264
|
+
else process.env[TERRAFORM_LIVE_PLAN_DIR_ENV] = value;
|
|
265
|
+
try {
|
|
266
|
+
return await fn();
|
|
267
|
+
} finally {
|
|
268
|
+
if (before === undefined) delete process.env[TERRAFORM_LIVE_PLAN_DIR_ENV];
|
|
269
|
+
else process.env[TERRAFORM_LIVE_PLAN_DIR_ENV] = before;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
it("writes the document the verdicts came from, at either depth, as <root>.live-plan.json", async () => {
|
|
274
|
+
for (const depth of ["plan", "state"] as const) {
|
|
275
|
+
const out = mkdtempSync(join(tmpdir(), "chant-live-plan-dir-"));
|
|
276
|
+
try {
|
|
277
|
+
const result = await withEnv(out, async () => describeResources(await options("choudoufu", depth), deps()));
|
|
278
|
+
expect(normalizeObservation(result).resources["estate/aws_vpc.main"]!.ownership).toBe("owned");
|
|
279
|
+
expect(readdirSync(out)).toEqual([livePlanDocumentFile("estate")]);
|
|
280
|
+
expect(JSON.parse(readFileSync(join(out, "estate.live-plan.json"), "utf-8"))).toEqual(LIVE_PLAN);
|
|
281
|
+
} finally {
|
|
282
|
+
rmSync(out, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("writes nothing unless asked, and nothing for a relative path", async () => {
|
|
288
|
+
const out = mkdtempSync(join(tmpdir(), "chant-live-plan-dir-"));
|
|
289
|
+
try {
|
|
290
|
+
await withEnv(undefined, async () => describeResources(await options(), deps()));
|
|
291
|
+
await withEnv("relative/dir", async () => describeResources(await options(), deps()));
|
|
292
|
+
expect(readdirSync(out)).toEqual([]);
|
|
293
|
+
expect(existsSync(join(ROOT_DIR, "relative"))).toBe(false);
|
|
294
|
+
} finally {
|
|
295
|
+
rmSync(out, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("a root name that is not a file name is made one", () => {
|
|
300
|
+
expect(livePlanDocumentFile("app/prod")).toBe("app_prod.live-plan.json");
|
|
301
|
+
});
|
|
302
|
+
});
|