@intentius/chant-lexicon-terraform 0.124.44 → 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.
Files changed (93) hide show
  1. package/dist/__fixtures__/capabilities/chant.config.d.ts +26 -0
  2. package/dist/__fixtures__/capabilities/chant.config.d.ts.map +1 -0
  3. package/dist/__fixtures__/capabilities/chant.config.js +13 -0
  4. package/dist/__fixtures__/effective-topology/chant.config.d.ts +13 -0
  5. package/dist/__fixtures__/effective-topology/chant.config.d.ts.map +1 -0
  6. package/dist/__fixtures__/effective-topology/chant.config.js +9 -0
  7. package/dist/__fixtures__/kind-type/chant.config.d.ts +13 -0
  8. package/dist/__fixtures__/kind-type/chant.config.d.ts.map +1 -0
  9. package/dist/__fixtures__/kind-type/chant.config.js +9 -0
  10. package/dist/__fixtures__/search-roots/chant.config.d.ts +13 -0
  11. package/dist/__fixtures__/search-roots/chant.config.d.ts.map +1 -0
  12. package/dist/__fixtures__/search-roots/chant.config.js +9 -0
  13. package/dist/__fixtures__/sensitive-default/chant.config.d.ts +13 -0
  14. package/dist/__fixtures__/sensitive-default/chant.config.d.ts.map +1 -0
  15. package/dist/__fixtures__/sensitive-default/chant.config.js +9 -0
  16. package/dist/capabilities/adapter.d.ts +9 -0
  17. package/dist/capabilities/adapter.d.ts.map +1 -0
  18. package/dist/capabilities/adapter.js +25 -0
  19. package/dist/capabilities/mapping.json +166 -0
  20. package/dist/config.d.ts +52 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +23 -0
  23. package/dist/describe-resources.d.ts +101 -8
  24. package/dist/describe-resources.d.ts.map +1 -1
  25. package/dist/describe-resources.js +395 -56
  26. package/dist/hcl/parse.d.ts +10 -0
  27. package/dist/hcl/parse.d.ts.map +1 -1
  28. package/dist/hcl/parse.js +20 -2
  29. package/dist/hcl/redact.d.ts +26 -0
  30. package/dist/hcl/redact.d.ts.map +1 -0
  31. package/dist/hcl/redact.js +228 -0
  32. package/dist/integrity.json +2 -2
  33. package/dist/manifest.json +1 -1
  34. package/dist/op/activities/terraform.d.ts +109 -4
  35. package/dist/op/activities/terraform.d.ts.map +1 -1
  36. package/dist/op/activities/terraform.js +148 -7
  37. package/dist/plan-observation.d.ts +153 -0
  38. package/dist/plan-observation.d.ts.map +1 -0
  39. package/dist/plan-observation.js +354 -0
  40. package/dist/plugin.d.ts.map +1 -1
  41. package/dist/plugin.js +2 -0
  42. package/package.json +2 -2
  43. package/src/__fixtures__/ACCEPTANCE.md +53 -1
  44. package/src/__fixtures__/capabilities/catalog/main.tf +105 -0
  45. package/src/__fixtures__/capabilities/chant.config.ts +15 -0
  46. package/src/__fixtures__/capabilities/findings/main.tf +225 -0
  47. package/src/__fixtures__/capabilities/ledger/main.tf +265 -0
  48. package/src/__fixtures__/capabilities/package.json +1 -0
  49. package/src/__fixtures__/effective-topology/chant.config.ts +11 -0
  50. package/src/__fixtures__/effective-topology/net/main.tf +176 -0
  51. package/src/__fixtures__/effective-topology/package.json +6 -0
  52. package/src/__fixtures__/kind-type/app/main.tf +35 -0
  53. package/src/__fixtures__/kind-type/chant.config.ts +11 -0
  54. package/src/__fixtures__/kind-type/package.json +6 -0
  55. package/src/__fixtures__/live-plan-depth/README.md +59 -0
  56. package/src/__fixtures__/live-plan-depth/live-plan.json +110 -0
  57. package/src/__fixtures__/live-plan-depth/show-plan-sensitive.json +1 -0
  58. package/src/__fixtures__/live-plan-depth/show-plan.json +1 -0
  59. package/src/__fixtures__/plan-read/main.tf +46 -0
  60. package/src/__fixtures__/plan-read/next.tfvars +1 -0
  61. package/src/__fixtures__/plan-read-drift/README.md +33 -0
  62. package/src/__fixtures__/plan-read-drift/main.tf +63 -0
  63. package/src/__fixtures__/plan-read-drift/next.tfvars +1 -0
  64. package/src/__fixtures__/search-roots/chant.config.ts +11 -0
  65. package/src/__fixtures__/search-roots/net/main.tf +73 -0
  66. package/src/__fixtures__/search-roots/package.json +6 -0
  67. package/src/__fixtures__/sensitive-default/chant.config.ts +11 -0
  68. package/src/__fixtures__/sensitive-default/net/main.tf +38 -0
  69. package/src/__fixtures__/sensitive-default/package.json +6 -0
  70. package/src/__fixtures__/show-plan.json +663 -0
  71. package/src/capabilities/adapter.ts +32 -0
  72. package/src/capabilities/mapping.json +166 -0
  73. package/src/capabilities-workspace.e2e.test.ts +251 -0
  74. package/src/capabilities.test.ts +264 -0
  75. package/src/config.ts +26 -0
  76. package/src/describe-resources.live-plan.test.ts +302 -0
  77. package/src/describe-resources.live-root-unchanged.test.ts +126 -0
  78. package/src/describe-resources.plan.test.ts +437 -0
  79. package/src/describe-resources.ts +445 -52
  80. package/src/effective-topology.test.ts +134 -0
  81. package/src/hcl/parse.ts +30 -2
  82. package/src/hcl/redact.ts +181 -0
  83. package/src/live-plan-depth.acceptance.e2e.test.ts +193 -0
  84. package/src/op/activities/terraform.test.ts +118 -1
  85. package/src/op/activities/terraform.ts +213 -9
  86. package/src/plan-observation.ts +428 -0
  87. package/src/plan-read.acceptance.test.ts +184 -0
  88. package/src/plugin.ts +2 -0
  89. package/src/redact-ir.e2e.test.ts +79 -0
  90. package/src/redact.test.ts +111 -0
  91. package/src/search-build-roots.test.ts +125 -0
  92. package/src/search-count.test.ts +144 -0
  93. package/src/search-kind-type.test.ts +136 -0
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Core's effective-topology folds over a terraform root (chant #4362).
3
+ *
4
+ * `enrichEffectiveTopology` used to select nodes by CloudFormation kind, and
5
+ * every terraform resource is `Terraform::Resource`, so on a terraform root
6
+ * `attr:internetFacing=true` matched nothing. The fixture
7
+ * (`src/__fixtures__/effective-topology/`) holds three instances and writes
8
+ * each route spelling and each ingress spelling once, on a path of its own,
9
+ * so every test below fails when the code for its spelling is removed.
10
+ *
11
+ * web public (inline `route`), SSH open via a launch template's group (inline `ingress`)
12
+ * batch public (`aws_route`), HTTPS via `security_groups` (`aws_vpc_security_group_ingress_rule`)
13
+ * db private (NAT route), SSH open via `vpc_security_group_ids` (`aws_security_group_rule`)
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 { enrichEffectiveTopology, terraformResourceType } from "@intentius/chant/graph-effective";
21
+ import { __searchInternals, runSearch } from "@intentius/chant/cli/handlers/search";
22
+ import { renderTerraformRoots } from "./hcl/roots";
23
+ import { terraformPlugin } from "./plugin";
24
+
25
+ const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__", "effective-topology");
26
+ const QUERY = "attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0";
27
+
28
+ let ir: GraphIR;
29
+ const attrs = (address: string): Record<string, unknown> =>
30
+ ir.nodes.find((n) => n.id === `net/${address}`)!.attrs as Record<string, unknown>;
31
+
32
+ beforeAll(async () => {
33
+ const rendered = await renderTerraformRoots({ projectRoot: FIXTURE, roots: { net: { dir: "./net" } } });
34
+ expect(rendered.warnings).toEqual([]);
35
+ ir = enrichEffectiveTopology(buildGraphIr(rendered.entities));
36
+ });
37
+
38
+ describe("terraformResourceType", () => {
39
+ it("reads the provider type off a terraform resource's address, and nothing else's", () => {
40
+ const web = ir.nodes.find((n) => n.id === "net/aws_instance.web");
41
+ expect(terraformResourceType(web)).toBe("aws_instance");
42
+ expect(terraformResourceType({ id: "x", kind: "AWS::EC2::Instance", attrs: {} } as never)).toBeUndefined();
43
+ });
44
+
45
+ it("reads the lexicon's attrs.type first, and the address only without one (#4366)", () => {
46
+ expect(ir.nodes.find((n) => n.id === "net/aws_instance.web")?.attrs.type).toBe("aws_instance");
47
+ const node = (attrs: Record<string, unknown>) => ({ id: "r/x.y", kind: "Terraform::Resource", attrs }) as never;
48
+ expect(terraformResourceType(node({ type: "aws_subnet", address: "aws_instance.y" }))).toBe("aws_subnet");
49
+ expect(terraformResourceType(node({ address: "module.app.aws_instance.y" }))).toBe("aws_instance");
50
+ });
51
+ });
52
+
53
+ describe("internetFacing on terraform's AWS kinds", () => {
54
+ it("follows an inline `route` block to the internet gateway", () => {
55
+ expect(attrs("aws_instance.web").internetFacing).toBe(true);
56
+ expect(attrs("aws_instance.web").internetFacingVia).toBe(
57
+ "subnet route aws_subnet.public_a -> aws_route_table.public_inline -> aws_internet_gateway.main (inline route)",
58
+ );
59
+ });
60
+
61
+ it("follows a separate `aws_route` resource to the internet gateway", () => {
62
+ expect(attrs("aws_instance.batch").internetFacing).toBe(true);
63
+ expect(attrs("aws_instance.batch").internetFacingVia).toBe(
64
+ "subnet route aws_subnet.public_b -> aws_route_table.public_separate -> aws_internet_gateway.main (aws_route.public_separate_default)",
65
+ );
66
+ });
67
+
68
+ it("does not count a default route to a NAT gateway", () => {
69
+ expect(attrs("aws_instance.db").internetFacing).toBe(false);
70
+ expect(attrs("aws_instance.db").internetFacingVia).toBeUndefined();
71
+ });
72
+ });
73
+
74
+ describe("effectiveIngress on terraform's AWS kinds", () => {
75
+ it("reads an inline `ingress` block on a group reached through a launch template", () => {
76
+ expect(attrs("aws_instance.web").effectiveIngress).toEqual(["tcp:22:0.0.0.0/0"]);
77
+ expect(attrs("aws_instance.web").effectiveIngressVia).toBe(
78
+ "security groups aws_launch_template.web -> aws_security_group.web",
79
+ );
80
+ });
81
+
82
+ it("reads an `aws_security_group_rule` of type ingress, and skips its egress sibling", () => {
83
+ expect(attrs("aws_instance.db").effectiveIngress).toEqual(["tcp:22:0.0.0.0/0"]);
84
+ });
85
+
86
+ it("reads an `aws_vpc_security_group_ingress_rule` on a group reached through `security_groups`", () => {
87
+ expect(attrs("aws_instance.batch").effectiveIngress).toEqual(["tcp:443:0.0.0.0/0"]);
88
+ });
89
+
90
+ it("records the derived surface under the terraform type", () => {
91
+ expect(ir.derivedAttrs?.["aws_instance"]).toEqual(["internetFacing", "effectiveIngress"]);
92
+ });
93
+ });
94
+
95
+ describe(`search "${QUERY}"`, () => {
96
+ const { parseQuery, matchTerm, explain } = __searchInternals;
97
+ afterEach(() => vi.restoreAllMocks());
98
+
99
+ it("matches exactly the public instance with SSH open through its launch template", () => {
100
+ const terms = parseQuery(QUERY);
101
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
102
+ const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, byId)));
103
+ expect(matches.map((n) => n.id)).toEqual(["net/aws_instance.web"]);
104
+ });
105
+
106
+ it("--explain prints the inclusion evidence for the match", () => {
107
+ const lines: string[] = [];
108
+ vi.spyOn(console, "log").mockImplementation((line: unknown) => void lines.push(String(line)));
109
+ const terms = parseQuery(QUERY);
110
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
111
+ const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, byId)));
112
+ explain(terms, matches, ir, byId, QUERY);
113
+ expect(lines).toContain(
114
+ " ✓ net/aws_instance.web internetFacing via subnet route aws_subnet.public_a -> aws_route_table.public_inline -> aws_internet_gateway.main (inline route)",
115
+ );
116
+ });
117
+
118
+ it("end to end: reads the root from config and says the facts are declared-only", async () => {
119
+ const lines: string[] = [];
120
+ vi.spyOn(console, "log").mockImplementation((line: unknown) => void lines.push(String(line)));
121
+ const cwd = process.cwd();
122
+ process.chdir(FIXTURE);
123
+ try {
124
+ const exit = await runSearch({ args: { path: QUERY } as never, plugins: [terraformPlugin], serializers: [] });
125
+ expect(exit).toBe(0);
126
+ } finally {
127
+ process.chdir(cwd);
128
+ }
129
+ expect(lines).toEqual([
130
+ "net/aws_instance.web aws_instance",
131
+ "— declared only · no observation · physical ids unavailable · effectiveIngress, internetFacing derived from declared references",
132
+ ]);
133
+ });
134
+ });
package/src/hcl/parse.ts CHANGED
@@ -25,6 +25,7 @@ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable"
25
25
  import type { EntityReference } from "@intentius/chant/graph-ir";
26
26
  import type { SuppressionDirective } from "@intentius/chant/lint/suppressions";
27
27
  import type { TerraformDeleteMode } from "../config";
28
+ import { redactSensitiveBody, redactSensitiveSource } from "./redact";
28
29
  import { scanSuppressions, directivesFor, type FileScan } from "./suppressions";
29
30
 
30
31
  /** A parsed HCL block body, as `@cdktn/hcl2json` encodes it. */
@@ -52,6 +53,16 @@ export interface TerraformEntity extends Declarable {
52
53
  readonly props: {
53
54
  /** Terraform address, e.g. `aws_s3_bucket.assets`, `var.region`, `module.cdn`. */
54
55
  readonly address: string;
56
+ /**
57
+ * A `resource` or `data` block's type and name, its two labels
58
+ * (`aws_instance` and `web` for `aws_instance.web`, `aws_ami` and `ubuntu`
59
+ * for `data.aws_ami.ubuntu`). Absent on every other block. Every resource
60
+ * is `Terraform::Resource` to chant, so this is what core's
61
+ * `terraformType()` reads for `chant search "kind:aws_instance"`,
62
+ * `--count-by kind` and the effective-topology folds (chant #4366).
63
+ */
64
+ readonly type?: string;
65
+ readonly name?: string;
55
66
  /** The block body, verbatim from hcl2json (interpolations survive as `"${...}"`). */
56
67
  readonly body: BlockBody;
57
68
  /** File the block came from, as named by the parse input. */
@@ -223,6 +234,9 @@ export function terraformEntity(
223
234
  suppressions?: readonly SuppressionDirective[],
224
235
  ): TerraformEntity {
225
236
  const expansion = expansionOf(body);
237
+ const labels = entityType === RESOURCE_TYPE ? blockLabels(address)
238
+ : entityType === DATA_TYPE && address.startsWith("data.") ? blockLabels(address.slice("data.".length))
239
+ : undefined;
226
240
  return {
227
241
  [DECLARABLE_MARKER]: true,
228
242
  lexicon: "terraform",
@@ -231,6 +245,7 @@ export function terraformEntity(
231
245
  stack: root,
232
246
  props: {
233
247
  address,
248
+ ...(labels ?? {}),
234
249
  body,
235
250
  file,
236
251
  root,
@@ -248,6 +263,13 @@ export function terraformEntity(
248
263
  };
249
264
  }
250
265
 
266
+ /** The type and name labels of a resource address (`aws_instance.web`), or undefined when it is not two labels. */
267
+ function blockLabels(address: string): { type: string; name: string } | undefined {
268
+ const dot = address.indexOf(".");
269
+ if (dot <= 0 || dot === address.length - 1 || address.indexOf(".", dot + 1) !== -1) return undefined;
270
+ return { type: address.slice(0, dot), name: address.slice(dot + 1) };
271
+ }
272
+
251
273
  /** One `.tf` file's name and source. */
252
274
  export interface TerraformFile {
253
275
  name: string;
@@ -388,6 +410,9 @@ export async function blocksToEntities(
388
410
  const entities = new Map<string, Declarable>();
389
411
  const prefix = callers.length > 0 ? `${root}/${callers.join("/")}` : root;
390
412
 
413
+ /** Each file's text is carried by every entity of the file: redact it once. */
414
+ const redacted = new Map<string, string>();
415
+
391
416
  const add = (
392
417
  entityType: string,
393
418
  address: string,
@@ -397,10 +422,13 @@ export async function blocksToEntities(
397
422
  scanAddress: string = address,
398
423
  ): void => {
399
424
  const { line, suppressions } = directivesFor(scan, scanAddress);
425
+ let source = redacted.get(file.source);
426
+ if (source === undefined) redacted.set(file.source, (source = redactSensitiveSource(file.source)));
427
+ const plain = (typeof body === "object" && body !== null ? body : {}) as BlockBody;
400
428
  const entity = terraformEntity(
401
429
  entityType,
402
430
  address,
403
- (typeof body === "object" && body !== null ? body : {}) as BlockBody,
431
+ entityType === VARIABLE_TYPE ? redactSensitiveBody(plain) : plain,
404
432
  file.name,
405
433
  root,
406
434
  {
@@ -409,7 +437,7 @@ export async function blocksToEntities(
409
437
  ...(modeOptions?.configEstate !== undefined ? { configEstate: modeOptions.configEstate } : {}),
410
438
  ...(callers.length > 0 ? { callers } : {}),
411
439
  },
412
- file.source,
440
+ source,
413
441
  line,
414
442
  suppressions,
415
443
  );
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Leave a sensitive variable's literal default out of the IR (#4377).
3
+ *
4
+ * The lexicon puts the HCL text of the file a block lives in on each entity's
5
+ * `source`, and the parsed block on `body`. A `variable` declared
6
+ * `sensitive = true` with a literal `default` carried that default in both, so
7
+ * `chant graph`, `chant search --show` and `workspace graph` printed it. This
8
+ * replaces the default with {@link SENSITIVE_PLACEHOLDER} (the same text the
9
+ * plan read shows) before the entity is built, so every reader gets the
10
+ * redacted form.
11
+ *
12
+ * `body` keeps its `default` key: TF006 asks whether a sensitive variable
13
+ * declares a default, and that fact survives. Only the value goes.
14
+ *
15
+ * The source scanner skips strings (with `${}`), heredocs and comments, finds
16
+ * each top-level `variable` block, and inside it the `sensitive` and `default`
17
+ * attributes. The default's whole expression is replaced, whether it is a
18
+ * string, a number, a list or a map spanning lines. Only a literal
19
+ * `sensitive = true` counts. Ported from behold's src/terraform-redact.ts.
20
+ */
21
+
22
+ export const SENSITIVE_PLACEHOLDER = "(sensitive)";
23
+
24
+ const WORD = /[A-Za-z_][A-Za-z0-9_-]*/y;
25
+ const HEREDOC = /<<-?([A-Za-z_][A-Za-z0-9_]*)[ \t]*\r?\n/y;
26
+
27
+ /** Index just past the string opened at `i` (a `"`), interpolations included. */
28
+ function skipString(t: string, i: number): number {
29
+ i++;
30
+ while (i < t.length) {
31
+ const c = t[i];
32
+ if (c === "\\") i += 2;
33
+ else if (c === "$" && t[i + 1] === "$" && t[i + 2] === "{") i += 3;
34
+ else if ((c === "$" || c === "%") && t[i + 1] === "{") i = skipBraces(t, i + 1);
35
+ else if (c === '"') return i + 1;
36
+ else i++;
37
+ }
38
+ return t.length;
39
+ }
40
+
41
+ /** Index just past the heredoc whose opener starts at `i`, or -1 when `i` is not one. */
42
+ function skipHeredoc(t: string, i: number): number {
43
+ HEREDOC.lastIndex = i;
44
+ const m = HEREDOC.exec(t);
45
+ if (!m) return -1;
46
+ let p = HEREDOC.lastIndex;
47
+ while (p < t.length) {
48
+ let e = t.indexOf("\n", p);
49
+ if (e < 0) e = t.length;
50
+ if (t.slice(p, e).trim() === m[1]) return e;
51
+ p = e + 1;
52
+ }
53
+ return t.length;
54
+ }
55
+
56
+ /** Index just past a comment starting at `i`, or -1 when there is none. A line comment stops before its newline. */
57
+ function skipComment(t: string, i: number): number {
58
+ if (t[i] === "#" || (t[i] === "/" && t[i + 1] === "/")) {
59
+ const e = t.indexOf("\n", i);
60
+ return e < 0 ? t.length : e;
61
+ }
62
+ if (t[i] === "/" && t[i + 1] === "*") {
63
+ const e = t.indexOf("*/", i + 2);
64
+ return e < 0 ? t.length : e + 2;
65
+ }
66
+ return -1;
67
+ }
68
+
69
+ /** Index just past the bracket group opened at `i` (`{`, `[` or `(`). */
70
+ function skipBraces(t: string, i: number): number {
71
+ let depth = 0;
72
+ while (i < t.length) {
73
+ const c = t[i];
74
+ if (c === '"') { i = skipString(t, i); continue; }
75
+ const h = c === "<" && t[i + 1] === "<" ? skipHeredoc(t, i) : -1;
76
+ if (h >= 0) { i = h; continue; }
77
+ const k = skipComment(t, i);
78
+ if (k >= 0) { i = k; continue; }
79
+ if (c === "{" || c === "[" || c === "(") depth++;
80
+ else if (c === "}" || c === "]" || c === ")") {
81
+ depth--;
82
+ if (depth === 0) return i + 1;
83
+ }
84
+ i++;
85
+ }
86
+ return t.length;
87
+ }
88
+
89
+ /** End of the expression starting at `i`: the last token before a newline or comma outside brackets, or the block's closing brace. */
90
+ function valueEnd(t: string, i: number, limit: number): number {
91
+ let last = i;
92
+ while (i < limit) {
93
+ const c = t[i];
94
+ if (c === "\n" || c === ",") break;
95
+ if (c === '"') { i = skipString(t, i); last = i; continue; }
96
+ const h = c === "<" && t[i + 1] === "<" ? skipHeredoc(t, i) : -1;
97
+ if (h >= 0) { i = h; last = i; continue; }
98
+ const k = skipComment(t, i);
99
+ if (k >= 0) { i = k; continue; }
100
+ if (c === "{" || c === "[" || c === "(") { i = skipBraces(t, i); last = i; continue; }
101
+ if (c !== " " && c !== "\t" && c !== "\r") last = i + 1;
102
+ i++;
103
+ }
104
+ return Math.min(last, limit);
105
+ }
106
+
107
+ interface Attr {
108
+ name: string;
109
+ from: number;
110
+ to: number;
111
+ }
112
+
113
+ /** The attributes written directly in a block body spanning [from, to). */
114
+ function attributes(t: string, from: number, to: number): Attr[] {
115
+ const out: Attr[] = [];
116
+ let i = from;
117
+ while (i < to) {
118
+ const c = t[i];
119
+ if (c === '"') { i = skipString(t, i); continue; }
120
+ const k = skipComment(t, i);
121
+ if (k >= 0) { i = k; continue; }
122
+ if (c === "{" || c === "[" || c === "(") { i = skipBraces(t, i); continue; }
123
+ WORD.lastIndex = i;
124
+ const m = WORD.exec(t);
125
+ if (!m) { i++; continue; }
126
+ i = WORD.lastIndex;
127
+ let p = i;
128
+ while (t[p] === " " || t[p] === "\t") p++;
129
+ if (t[p] === "=" && t[p + 1] !== "=" && t[p + 1] !== ">") {
130
+ p++;
131
+ while (t[p] === " " || t[p] === "\t") p++;
132
+ const end = valueEnd(t, p, to);
133
+ out.push({ name: m[0], from: p, to: end });
134
+ i = Math.max(end, p);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /** `source` with the literal default of every `sensitive = true` variable replaced by `(sensitive)`. */
141
+ export function redactSensitiveSource(source: string): string {
142
+ if (!source.includes("sensitive")) return source;
143
+ const spans: [number, number][] = [];
144
+ let i = 0;
145
+ let variable = false;
146
+ while (i < source.length) {
147
+ const c = source[i];
148
+ if (c === "\n") variable = false;
149
+ if (c === '"') { i = skipString(source, i); continue; }
150
+ const k = skipComment(source, i);
151
+ if (k >= 0) { i = k; continue; }
152
+ if (c === "{") {
153
+ const end = skipBraces(source, i);
154
+ if (variable) {
155
+ const attrs = attributes(source, i + 1, end - 1);
156
+ const sensitive = attrs.some((a) => a.name === "sensitive" && source.slice(a.from, a.to) === "true");
157
+ if (sensitive) for (const a of attrs) if (a.name === "default") spans.push([a.from, a.to]);
158
+ }
159
+ variable = false;
160
+ i = end;
161
+ continue;
162
+ }
163
+ WORD.lastIndex = i;
164
+ const m = WORD.exec(source);
165
+ if (m) {
166
+ variable = m[0] === "variable";
167
+ i = WORD.lastIndex;
168
+ continue;
169
+ }
170
+ i++;
171
+ }
172
+ let out = source;
173
+ for (const [a, b] of spans.reverse()) out = out.slice(0, a) + SENSITIVE_PLACEHOLDER + out.slice(b);
174
+ return out;
175
+ }
176
+
177
+ /** A variable body with its `default` replaced, when the variable is a literal `sensitive = true` with a default. Otherwise the body itself. */
178
+ export function redactSensitiveBody<T extends Record<string, unknown>>(body: T): T {
179
+ if (body.sensitive !== true || !("default" in body)) return body;
180
+ return { ...body, default: SENSITIVE_PLACEHOLDER };
181
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The plan depth on a live root against a real choudoufu (#4371).
3
+ *
4
+ * Copies `src/__fixtures__/live-estate/` into a temp project under a fresh
5
+ * estate name, so a re-run against the same emulator never meets an earlier
6
+ * run's resources. Setup is what the fixture's README records: apply with
7
+ * `unowned.tf`, `rule.tf` and `adoptable.tf` held back, create the two
8
+ * unowned log groups out of band, then drift two bound resources out of band
9
+ * (a log group's retention, a tag on the VPC) and give the VPC a tag from a
10
+ * `sensitive` variable whose value then changes. `adoptable.tf` stays out:
11
+ * its content match needs a sweep, which an observation read never asks for.
12
+ *
13
+ * Then one `describeResources` at the plan depth, with the real activities.
14
+ *
15
+ * Gated on a `choudoufu` on PATH, an `aws` CLI on PATH and
16
+ * `CHOUDOUFU_EMULATOR_ENDPOINT`, and skipped with the reason named otherwise,
17
+ * like the other acceptance suites (`__fixtures__/ACCEPTANCE.md`). An e2e
18
+ * file: an apply and two reads against the emulator take about a minute.
19
+ */
20
+
21
+ import { execSync, spawnSync } from "node:child_process";
22
+ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { afterAll, describe, expect, it } from "vitest";
27
+ import { normalizeObservation } from "@intentius/chant/observation";
28
+ import type { Declarable } from "@intentius/chant/declarable";
29
+ import { describeResources } from "./describe-resources";
30
+ import { renderTerraformRoots } from "./hcl/roots";
31
+ import { PLAN_READ_DIR_PREFIX } from "./op/activities/terraform";
32
+ import { SENSITIVE_VALUE } from "./plan-observation";
33
+
34
+ const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__", "live-estate");
35
+ const emulatorEndpoint = process.env.CHOUDOUFU_EMULATOR_ENDPOINT;
36
+
37
+ function onPath(cmd: string): boolean {
38
+ try {
39
+ execSync(`command -v ${cmd}`, { stdio: "ignore" });
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ const missing = !onPath("choudoufu")
47
+ ? "choudoufu is not on PATH"
48
+ : !onPath("aws")
49
+ ? "the aws CLI is not on PATH"
50
+ : !emulatorEndpoint
51
+ ? "CHOUDOUFU_EMULATOR_ENDPOINT is not set (bring up choudoufu's `just smoke` emulator stack and export it)"
52
+ : undefined;
53
+
54
+ const workspaces: string[] = [];
55
+ afterAll(() => {
56
+ for (const dir of workspaces) rmSync(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ function env(): NodeJS.ProcessEnv {
60
+ return {
61
+ ...process.env,
62
+ AWS_ENDPOINT_URL: process.env.AWS_ENDPOINT_URL ?? emulatorEndpoint,
63
+ AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID ?? "choudoufu-emulator",
64
+ AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ?? "choudoufu-emulator",
65
+ AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION ?? "us-east-1",
66
+ AWS_REGION: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1",
67
+ TF_IN_AUTOMATION: "1",
68
+ };
69
+ }
70
+
71
+ function run(cmd: string, args: string[], cwd: string): string {
72
+ const r = spawnSync(cmd, args, { cwd, encoding: "utf-8", env: env() });
73
+ if (r.status !== 0) throw new Error(`${cmd} ${args.join(" ")} failed (exit ${r.status}): ${r.stderr}`);
74
+ return r.stdout;
75
+ }
76
+
77
+ /** Every entry under `dir`, recursively, with its size and mtime: what "nothing new" compares. */
78
+ function snapshot(dir: string): string[] {
79
+ return readdirSync(dir, { recursive: true })
80
+ .map(String)
81
+ .sort()
82
+ .map((rel) => {
83
+ const st = statSync(join(dir, rel));
84
+ return `${rel} ${st.isDirectory() ? "dir" : `${st.size} ${st.mtimeMs}`}`;
85
+ });
86
+ }
87
+
88
+ function planDirs(): string[] {
89
+ return readdirSync(tmpdir()).filter((n) => n.startsWith(PLAN_READ_DIR_PREFIX)).sort();
90
+ }
91
+
92
+ describe.skipIf(missing !== undefined)(
93
+ `the plan depth on a live root against a real choudoufu (#4371)${missing ? `: skipped, ${missing}` : ""}`,
94
+ () => {
95
+ it("puts the plan on bound blocks beside live-plan's ownership, redacts the secret, writes nothing in the root", async () => {
96
+ const estate = `chant-4371-${Date.now().toString(36)}`;
97
+ const dir = mkdtempSync(join(tmpdir(), "chant-live-plan-depth-"));
98
+ workspaces.push(dir);
99
+ const root = join(dir, "root");
100
+ cpSync(FIXTURE, root, { recursive: true });
101
+ rmSync(join(root, "README.md"));
102
+ for (const file of readdirSync(root)) {
103
+ const path = join(root, file);
104
+ writeFileSync(path, readFileSync(path, "utf-8").replaceAll("stateless-e2e-block", estate));
105
+ }
106
+ const network = join(root, "network.tf");
107
+ writeFileSync(
108
+ network,
109
+ readFileSync(network, "utf-8").replace(
110
+ 'resource "aws_vpc" "main" {\n cidr_block = "10.99.0.0/16"\n}',
111
+ 'resource "aws_vpc" "main" {\n cidr_block = "10.99.0.0/16"\n tags = { token = var.token }\n}',
112
+ ),
113
+ );
114
+ const secret = (value: string) =>
115
+ writeFileSync(join(root, "secret.tf"), `variable "token" {\n type = string\n sensitive = true\n default = "${value}"\n}\n`);
116
+ secret(`old-${estate}`);
117
+ writeFileSync(
118
+ join(dir, "chant.config.json"),
119
+ JSON.stringify({ lexicons: ["terraform"], terraform: { binary: "choudoufu", observe: "plan", roots: { estate: { dir: "./root" } } } }),
120
+ );
121
+
122
+ // Apply with the three held back, as the fixture's README does.
123
+ const held = join(dir, "held");
124
+ mkdirSync(held);
125
+ for (const f of ["unowned.tf", "rule.tf", "adoptable.tf"]) renameSync(join(root, f), join(held, f));
126
+ run("choudoufu", ["init", "-input=false"], root);
127
+ run("choudoufu", ["apply", "-auto-approve", "-input=false"], root);
128
+ for (const f of ["unowned.tf", "rule.tf"]) renameSync(join(held, f), join(root, f));
129
+
130
+ run("aws", ["logs", "create-log-group", "--log-group-name", `/${estate}/adoptable`], root);
131
+ run("aws", ["logs", "create-log-group", "--log-group-name", `/${estate}/held-elsewhere`], root);
132
+ run("aws", ["logs", "tag-log-group", "--log-group-name", `/${estate}/held-elsewhere`, "--tags", "tofu-estate=other-estate,tofu-address=aws_cloudwatch_log_group.elsewhere"], root);
133
+ // Out-of-band drift on two bound resources.
134
+ run("aws", ["logs", "put-retention-policy", "--log-group-name", `/${estate}/app`, "--retention-in-days", "3"], root);
135
+ const vpc = run("aws", ["ec2", "describe-vpcs", "--filters", `Name=tag:tofu-estate,Values=${estate}`, "--query", "Vpcs[0].VpcId", "--output", "text"], root).trim();
136
+ expect(vpc).toMatch(/^vpc-/);
137
+ run("aws", ["ec2", "create-tags", "--resources", vpc, "--tags", "Key=drifted,Value=yes"], root);
138
+ // The live tag now holds the old secret; the configuration declares a new one.
139
+ secret(`new-${estate}`);
140
+
141
+ const { entities } = await renderTerraformRoots({ projectRoot: dir, roots: { estate: { dir: "./root" } }, binary: "choudoufu" });
142
+ const map = new Map<string, { entityType: string; props: Record<string, unknown> }>();
143
+ for (const [key, entity] of entities) {
144
+ map.set(key, { entityType: entity.entityType, props: (entity as Declarable & { props: Record<string, unknown> }).props });
145
+ }
146
+
147
+ const before = snapshot(root);
148
+ const tempBefore = planDirs();
149
+ const saved = { ...process.env };
150
+ Object.assign(process.env, env());
151
+ let raw;
152
+ try {
153
+ raw = await describeResources({ environment: "prod", buildOutput: "", entityNames: [...map.keys()].sort(), entities: map, cwd: dir });
154
+ } finally {
155
+ process.env = saved;
156
+ }
157
+ const result = normalizeObservation(raw);
158
+
159
+ // The read adds nothing to the root: neither the plan file nor the
160
+ // live-plan document (#4379).
161
+ expect(snapshot(root)).toEqual(before);
162
+ expect(planDirs()).toEqual(tempBefore);
163
+ expect(result.notes.join("\n")).toMatch(/estate: the plan read answered \(`choudoufu plan -input=false -lock=false -out=<tmp>\/plan\.tfplan`\)/);
164
+ expect(new Set(Object.values(result.sources))).toEqual(new Set(["live+plan"]));
165
+
166
+ const meta = (name: string) => result.resources[`estate/${name}`]!;
167
+ const attrs = (name: string) => meta(name).attributes as Record<string, unknown>;
168
+ expect(meta("aws_vpc.main").ownership).toBe("owned");
169
+ expect(meta("aws_vpc.main").marker).toEqual({ stack: estate });
170
+ expect(attrs("aws_cloudwatch_log_group.app")).toMatchObject({
171
+ planAction: "update",
172
+ planChanges: [{ path: "retention_in_days", before: 3, after: 1 }],
173
+ });
174
+ expect(attrs("aws_vpc.main").planChanges).toEqual(
175
+ expect.arrayContaining([
176
+ { path: "tags.drifted", before: "yes", after: null },
177
+ { path: "tags.token", before: SENSITIVE_VALUE, after: SENSITIVE_VALUE },
178
+ ]),
179
+ );
180
+ expect(attrs("aws_eip.pool").planAction).toBe("no-op");
181
+ expect(attrs("aws_subnet.app").planAction).toBe("no-op");
182
+
183
+ expect(meta("aws_cloudwatch_log_group.held_elsewhere").ownership).toBe("foreign");
184
+ expect(attrs("aws_cloudwatch_log_group.held_elsewhere").planAction).toBeUndefined();
185
+ expect(meta("aws_cloudwatch_log_group.adoptable").ownership).toBe("unknown");
186
+ expect(attrs("aws_cloudwatch_log_group.adoptable").planAction).toBeUndefined();
187
+ expect(result.resources["estate/aws_cloudwatch_log_group.never_applied"]).toBeUndefined();
188
+
189
+ expect(JSON.stringify(raw)).not.toContain(`old-${estate}`);
190
+ expect(JSON.stringify(raw)).not.toContain(`new-${estate}`);
191
+ }, 600_000);
192
+ },
193
+ );