infraweaver 0.3.11 → 0.3.13

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 (59) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/opencode.d.ts +6 -0
  3. package/dist/agents/quotaTokens.d.ts +6 -0
  4. package/dist/cli.mjs +846 -412
  5. package/dist/index.js +806 -372
  6. package/dist/internal.js +4 -4
  7. package/dist/mcp/git.d.ts +6 -4
  8. package/dist/mcp/localContext.d.ts +7 -0
  9. package/dist/mcp/reviewCommentLimit.d.ts +55 -5
  10. package/dist/mcp/reviewMarkers.d.ts +5 -0
  11. package/dist/mcp/staleFix.d.ts +36 -1
  12. package/dist/mcp/terraform/awsPrices.d.ts +1 -1
  13. package/dist/mcp/terraform/azurePrices.d.ts +1 -1
  14. package/dist/mcp/terraform/concernResult.d.ts +9 -0
  15. package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
  16. package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
  17. package/dist/mcp/terraform/refactorScope.d.ts +14 -2
  18. package/dist/mcp/terraform/ruleContext.d.ts +3 -0
  19. package/dist/mcp/terraform/scanSession.d.ts +2 -0
  20. package/dist/mcp/terraform/tree.d.ts +5 -0
  21. package/dist/mcp/terraform/types.d.ts +2 -1
  22. package/dist/mcp/terraform/verifySites.d.ts +56 -0
  23. package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
  24. package/package.json +2 -2
  25. package/src/agents/opencode.ts +8 -1
  26. package/src/agents/quotaTokens.ts +7 -1
  27. package/src/mcp/git.ts +64 -12
  28. package/src/mcp/localContext.ts +6 -1
  29. package/src/mcp/moduleExtraction.ts +5 -4
  30. package/src/mcp/review.ts +52 -3
  31. package/src/mcp/reviewCommentLimit.ts +169 -26
  32. package/src/mcp/reviewMarkers.ts +11 -0
  33. package/src/mcp/reviewProvenance.ts +1 -1
  34. package/src/mcp/staleFix.ts +73 -2
  35. package/src/mcp/terraform/awsPrices.ts +1 -1
  36. package/src/mcp/terraform/azurePrices.ts +23 -23
  37. package/src/mcp/terraform/concernResult.ts +18 -9
  38. package/src/mcp/terraform/deltaSummary.ts +4 -0
  39. package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
  40. package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
  41. package/src/mcp/terraform/nativeScan.ts +4 -0
  42. package/src/mcp/terraform/refactor/equivalence.ts +42 -9
  43. package/src/mcp/terraform/refactorScope.ts +90 -21
  44. package/src/mcp/terraform/ruleContext.ts +4 -0
  45. package/src/mcp/terraform/scanDelta.ts +67 -26
  46. package/src/mcp/terraform/scanSession.ts +6 -0
  47. package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
  48. package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
  49. package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
  50. package/src/mcp/terraform/tools/readFindings.ts +2 -0
  51. package/src/mcp/terraform/tools/scan.ts +3 -0
  52. package/src/mcp/terraform/tools/verifyRemediation.ts +23 -1
  53. package/src/mcp/terraform/tree.ts +8 -0
  54. package/src/mcp/terraform/types.ts +2 -1
  55. package/src/mcp/terraform/verifySites.ts +124 -0
  56. package/src/modes/refactor.ts +1 -1
  57. package/src/modes/remediate-and-refactor.ts +1 -1
  58. package/src/modes/remediate.ts +1 -1
  59. package/src/phases/runAgentWithWatchdogs.ts +3 -2
@@ -9,30 +9,30 @@ export const AZURE_PRICE_REGION = "westeurope";
9
9
  /** Currency of every price below. */
10
10
  export const AZURE_PRICE_CURRENCY = "EUR";
11
11
  /** ISO date the snapshot was captured — surfaced in cost.md so staleness is visible. */
12
- export const AZURE_PRICE_FETCHED_AT = "2026-09-03";
12
+ export const AZURE_PRICE_FETCHED_AT = "2026-10-03";
13
13
 
14
14
  /** meter key → list price per HOUR. */
15
15
  export const AZURE_HOURLY_PRICES: Record<string, number> = {
16
- "app-service-p0v3": 0.0764,
17
- "app-service-p1v3": 0.1528,
18
- "app-service-p2v3": 0.3057,
19
- "app-service-p3v3": 0.6114,
20
- "bastion-basic": 0.1631,
21
- "bastion-premium": 0.3864,
22
- "bastion-standard": 0.249,
23
- "firewall-basic": 0.3392,
24
- "firewall-premium": 1.5027,
25
- "firewall-standard": 1.0733,
26
- "redis-c0": 0.0236,
27
- "redis-c1": 0.0592,
28
- "redis-c2": 0.0962,
29
- "redis-c3": 0.1932,
30
- "redis-p1": 0.2378,
31
- "redis-p2": 0.4766,
32
- "redis-p3": 0.9523,
33
- "vpn-gateway-vpngw1": 0.1631,
34
- "vpn-gateway-vpngw2": 0.4207,
35
- "vpn-gateway-vpngw3": 1.0733,
36
- "vpn-gateway-vpngw4": 1.8032,
37
- "vpn-gateway-vpngw5": 3.1341,
16
+ "app-service-p0v3": 0.0783,
17
+ "app-service-p1v3": 0.1566,
18
+ "app-service-p2v3": 0.3133,
19
+ "app-service-p3v3": 0.6265,
20
+ "bastion-basic": 0.1672,
21
+ "bastion-premium": 0.396,
22
+ "bastion-standard": 0.2552,
23
+ "firewall-basic": 0.3476,
24
+ "firewall-premium": 1.54,
25
+ "firewall-standard": 1.1,
26
+ "redis-c0": 0.0242,
27
+ "redis-c1": 0.0607,
28
+ "redis-c2": 0.0986,
29
+ "redis-c3": 0.198,
30
+ "redis-p1": 0.2438,
31
+ "redis-p2": 0.4884,
32
+ "redis-p3": 0.9759,
33
+ "vpn-gateway-vpngw1": 0.1672,
34
+ "vpn-gateway-vpngw2": 0.4312,
35
+ "vpn-gateway-vpngw3": 1.1,
36
+ "vpn-gateway-vpngw4": 1.8479,
37
+ "vpn-gateway-vpngw5": 3.2119,
38
38
  };
@@ -1,5 +1,5 @@
1
1
  import type { LocalToolContext } from "#app/mcp/localContext";
2
- import { remediationBranchSuffix } from "#app/mcp/staleFix";
2
+ import { groupIdFromBranch, type OpenRemediationPr, remediationBranchFor } from "#app/mcp/staleFix";
3
3
  import {
4
4
  capSeededConcerns,
5
5
  type PrioritySignals,
@@ -25,6 +25,7 @@ import {
25
25
  type Severity,
26
26
  sortConcerns,
27
27
  } from "#app/mcp/terraform/types";
28
+ import { recordConcernSites } from "#app/mcp/terraform/verifySites";
28
29
 
29
30
  export interface ConcernResult {
30
31
  /** non-suppressed concerns ≥ threshold, sorted */
@@ -112,6 +113,14 @@ export function buildConcernResult(
112
113
  * the full raw set — so a capped concern the re-scan still sees can never
113
114
  * read as a regression. */
114
115
  seedCaps?: SeedCaps;
116
+ /** The repository's open remediation PRs (see `listOpenRemediationPrs`), so
117
+ * a group that already has one keeps its branch. */
118
+ openRemediationPrs?: readonly OpenRemediationPr[];
119
+ /** The concerns were scanned off the tree in `cwd` as it is now, so their
120
+ * lines name today's resources (see `recordConcernSites`). Only
121
+ * `terraform_scan` sets it: a findings file or external report may
122
+ * describe another commit. */
123
+ linesMatchTree?: boolean;
115
124
  },
116
125
  ): ConcernResult {
117
126
  const fullBaseline = dedupe(rawConcerns);
@@ -128,6 +137,7 @@ export function buildConcernResult(
128
137
  fullBaseline.map((c) => concernKeyOf(c)),
129
138
  );
130
139
  recordConcernKeys(session, fullBaseline);
140
+ recordConcernSites(session, fullBaseline, opts.cwd, opts.linesMatchTree ?? false);
131
141
  // the files already unformatted at this (pre-fix) scan. terraform_validate
132
142
  // subtracts these so PRE-EXISTING `terraform fmt` drift can't force a
133
143
  // whole-file reformat into a scoped fix. Captured from the raw set (fmt
@@ -154,17 +164,16 @@ export function buildConcernResult(
154
164
  active,
155
165
  opts.autonomyThreshold,
156
166
  );
157
- const suffix = remediationBranchSuffix(
158
- remediationBaseOf(ctx),
159
- ctx.repo?.data.default_branch?.trim() || undefined,
160
- );
167
+ const base = remediationBaseOf(ctx);
168
+ const defaultBranch = ctx.repo?.data.default_branch?.trim() || undefined;
169
+ const branchFor = (id: string) =>
170
+ remediationBranchFor(id, base, defaultBranch, opts.openRemediationPrs);
161
171
  const groups = (
162
172
  opts.priority ? prioritizeGroups(annotatedGroups, active, opts.priority) : annotatedGroups
163
- ).map((g) => ({ ...g, branch: `remediate/${g.id}${suffix}` }));
173
+ ).map((g) => ({ ...g, branch: branchFor(g.id) }));
164
174
  const planned = planBatches(groups);
165
- const batchPlan = planned.batch_branch
166
- ? { ...planned, batch_branch: `${planned.batch_branch}${suffix}` }
167
- : planned;
175
+ const batchId = planned.batch_branch ? groupIdFromBranch(planned.batch_branch) : null;
176
+ const batchPlan = batchId ? { ...planned, batch_branch: branchFor(batchId) } : planned;
168
177
 
169
178
  const bySeverity: Record<string, number> = {};
170
179
  for (const c of active) bySeverity[c.severity] = (bySeverity[c.severity] ?? 0) + 1;
@@ -42,6 +42,9 @@ export interface DeltaVerdict {
42
42
  gating: number;
43
43
  /** introduced findings on a required control, any severity */
44
44
  required: number;
45
+ /** introduced findings that fail either half, each counted once: a high
46
+ * finding on a required control is in both `gating` and `required`. */
47
+ blocking: number;
45
48
  }
46
49
 
47
50
  /** The gate's verdict on a delta: the same two halves `findings-gate` applies. */
@@ -53,6 +56,7 @@ export function deltaVerdict(delta: GateDelta, requiredControls: string[]): Delt
53
56
  pass: gate.pass && required.length === 0,
54
57
  gating: gate.gating.length,
55
58
  required: required.length,
59
+ blocking: new Set([...gate.gating, ...required]).size,
56
60
  };
57
61
  }
58
62
 
@@ -12,22 +12,28 @@
12
12
  * WHAT IS READ. Every `name = "…"` line inside a `resource`, `data`,
13
13
  * `provider`, `module` or `locals` block, at any depth, so
14
14
  * `environment { variables = { auth = "…" } }` and `jsonencode({ … })` objects
15
- * are reached; and the `default` of a `variable` whose name is a secret's. Not
16
- * read: comments, heredoc bodies, a name/value pair split across two keys
17
- * (`[{ name = "PASSWORD", value = "…" }]`), and a value that starts on a later
18
- * line than its name.
15
+ * are reached; the `default` of a `variable` whose name is a secret's; and
16
+ * every assignment in a `.tfvars` file, which is where a value kept out of the
17
+ * `.tf` source usually lands instead. Not read: comments, heredoc bodies, a
18
+ * name/value pair split across two keys (`[{ name = "PASSWORD", value = "…" }]`),
19
+ * and a value that starts on a later line than its name.
19
20
  *
20
- * WHAT IS NOT A FINDING. A reference, a function call, an interpolation, an
21
- * empty string, or a placeholder made only of `*` / `x` / `<…>`. Neither is an
22
- * argument whose name only mentions a secret (`password_length`, `secret_name`,
23
- * `token_validity_units`, `creation_token`): the last word of the name decides.
21
+ * WHAT IS NOT A FINDING. A reference, a function call, an interpolation or a
22
+ * template directive, an empty string, `true` / `false` / `null`, a number, or
23
+ * a placeholder made only of `*` / `x` / `<…>`. Nor is a literal that only POINTS
24
+ * at a secret — an ARN, a GCP `projects/…/secrets/…` name, a Key Vault secret
25
+ * URI or an SSM parameter path — since that is the fix, not the fault. Neither
26
+ * is an argument whose name only mentions a secret (`password_length`,
27
+ * `secret_name`, `token_validity_units`, `creation_token`): the last word of the
28
+ * name decides, and a name that is `secret` and nothing else names a Secret
29
+ * Manager secret (`secret_key_ref { secret = "db-password" }`), not its value.
24
30
  *
25
31
  * Arguments the provider can take write-only (`aws_db_instance.password`) may
26
32
  * also be reported by `secret-persisted-to-state`, which says something else:
27
33
  * that the value reaches state. This one says it is in git.
28
34
  */
29
35
 
30
- import { isStringLiteral, stripTrailingComment } from "#app/mcp/terraform/hcl";
36
+ import { isStringLiteral, skipNonCode, stripTrailingComment } from "#app/mcp/terraform/hcl";
31
37
  import { HARDCODED_SECRET_ARGUMENT, NATIVE_SOURCE } from "#app/mcp/terraform/nativeRules";
32
38
  import type { SourceFile } from "#app/mcp/terraform/tree";
33
39
  import { buildConcern, type Concern } from "#app/mcp/terraform/types";
@@ -46,7 +52,18 @@ const SECRET_WORDS = new Set([
46
52
  "credentials",
47
53
  ]);
48
54
  /** `<word>_key` names that hold a secret (`api_key`, `secret_key`, …). */
49
- const SECRET_KEY_PREFIXES = new Set(["api", "secret", "private", "signing"]);
55
+ const SECRET_KEY_PREFIXES = new Set([
56
+ "api",
57
+ "secret",
58
+ "private",
59
+ "signing",
60
+ "access",
61
+ "account",
62
+ "client",
63
+ "shared",
64
+ "preshared",
65
+ "master",
66
+ ]);
50
67
  /** `<word>_token` names that are identifiers, not credentials. */
51
68
  const NOT_SECRET_TOKENS = new Set(["creation", "idempotency", "client", "request", "continuation"]);
52
69
 
@@ -66,6 +83,9 @@ export function isSecretName(name: string): boolean {
66
83
  if (last === undefined) return false;
67
84
  if (last === "key") return prev !== undefined && SECRET_KEY_PREFIXES.has(prev);
68
85
  if (last === "token" && prev !== undefined && NOT_SECRET_TOKENS.has(prev)) return false;
86
+ if (last === "string") return prev === "connection";
87
+ // `secret` alone is the NAME of a Secret Manager secret (`secret_key_ref { secret = … }`)
88
+ if (last === "secret" && w.length === 1) return false;
69
89
  return SECRET_WORDS.has(last);
70
90
  }
71
91
 
@@ -75,13 +95,38 @@ function isPlaceholder(inner: string): boolean {
75
95
  inner.trim() === "" ||
76
96
  /^[*xX.]+$/.test(inner) ||
77
97
  /^<[^>]*>$/.test(inner) ||
78
- // the dummy credentials offline plans and LocalStack are given (`secret_key = "test"`)
79
- /^(?:test|mock|fake|dummy|fixture|example|placeholder|sample)(?:[_-].*)?$/i.test(inner) ||
98
+ /^(?:true|false|null)$/.test(inner) ||
99
+ /^-?\d+(?:\.\d+)?$/.test(inner) ||
100
+ // a template directive is computed, like an interpolation: `"%{ if … }…%{ endif }"`
101
+ inner.includes("%{") ||
102
+ // the dummy credentials offline plans and LocalStack are given (`secret_key = "test"`,
103
+ // `"example-secret"`) — but only when that is the whole value: a lowercase tail at
104
+ // most, so `"Test-Pr0dDbPassw0rd!"` is a password that happens to start with a word
105
+ isDummy(inner) ||
80
106
  // a token's lifetime unit: `token_validity_units { access_token = "hours" }`
81
107
  /^(?:seconds|minutes|hours|days)$/.test(inner)
82
108
  );
83
109
  }
84
110
 
111
+ function isDummy(inner: string): boolean {
112
+ const m = /^(?:test|mock|fake|dummy|fixture|example|placeholder|sample)([_-].*)?$/i.exec(inner);
113
+ return m !== null && (m[1] === undefined || /^[_-][a-z0-9_-]*$/.test(m[1]));
114
+ }
115
+
116
+ /**
117
+ * A literal that names where a secret is kept rather than holding it: a Secrets
118
+ * Manager / SSM ARN, a GCP Secret Manager resource name, a Key Vault secret URI,
119
+ * or an SSM parameter path. Passing one of these is the remedy this rule asks for.
120
+ */
121
+ function isSecretReference(inner: string): boolean {
122
+ return (
123
+ inner.startsWith("arn:") ||
124
+ /^projects\/[^/]+\/secrets\//.test(inner) ||
125
+ /^https:\/\/[\w-]+\.vault\.azure\.net\/secrets\//i.test(inner) ||
126
+ /^\/[\w./-]+$/.test(inner)
127
+ );
128
+ }
129
+
85
130
  export interface HardcodedSecret {
86
131
  /** the argument, map key or variable name. */
87
132
  name: string;
@@ -90,17 +135,52 @@ export interface HardcodedSecret {
90
135
  line: number;
91
136
  }
92
137
 
93
- /** a top-level block header: `resource "aws_db_instance" "db" {`, `locals {` */
94
- const HEADER = /^(resource|data|provider|module|locals|variable|output|terraform)\b([^{]*)\{\s*$/;
138
+ /** a top-level block header, `resource "aws_db_instance" "db" {`, and whatever follows its `{` */
139
+ const HEADER = /^(resource|data|provider|module|locals|variable|output|terraform)\b([^{]*)\{(.*)$/;
95
140
  /** `name = value`, the name bare or quoted */
96
141
  const ASSIGNMENT = /^\s*(?:"([A-Za-z_][\w-]*)"|([A-Za-z_][\w-]*))\s*=(?!=)\s*(.*)$/;
97
142
  const HEREDOC = /<<-?([A-Za-z_]\w*)\s*$/;
98
143
  /** a `key = "string"` pair inside a one-line object */
99
144
  const INLINE_PAIR = /(?:"([A-Za-z_][\w-]*)"|\b([A-Za-z_][\w-]*))\s*=(?!=)\s*("(?:[^"\\]|\\.)*")/g;
100
145
 
101
- function literalValue(raw: string): boolean {
146
+ function literalValue(name: string, raw: string): boolean {
102
147
  const value = stripTrailingComment(raw).replace(/,$/, "").trim();
103
- return isStringLiteral(value) && !isPlaceholder(value.slice(1, -1));
148
+ if (!isStringLiteral(value)) return false;
149
+ const inner = value.slice(1, -1);
150
+ if (isPlaceholder(inner) || isSecretReference(inner)) return false;
151
+ // an `auth` setting's mode, not its credential: `iam_auth = "DISABLED"`, `auth = "NONE"`
152
+ return !(words(name).at(-1) === "auth" && /^[A-Z]+(?:_[A-Z]+)*$/.test(inner));
153
+ }
154
+
155
+ /** the name a `variable "…"` holder declares. */
156
+ function variableName(holder: string): string {
157
+ return /^variable\s+"?([\w-]+)"?/.exec(holder)?.[1] ?? "";
158
+ }
159
+
160
+ /** True when `line` leaves a block comment open past its end (`foo = 1 /* …`). */
161
+ function opensBlockComment(line: string): boolean {
162
+ for (let i = 0; i < line.length; i++) {
163
+ const span = skipNonCode(line, i);
164
+ if (!span) continue;
165
+ if (line.startsWith("/*", i) && !line.includes("*/", i + 2)) return true;
166
+ i = span.end;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /** True when the text after a header's `{` also closes that block. */
172
+ function closesOnLine(rest: string): boolean {
173
+ let depth = 1;
174
+ for (let i = 0; i < rest.length; i++) {
175
+ const span = skipNonCode(rest, i);
176
+ if (span) {
177
+ i = span.end;
178
+ continue;
179
+ }
180
+ if (rest[i] === "{") depth++;
181
+ else if (rest[i] === "}" && --depth === 0) return true;
182
+ }
183
+ return false;
104
184
  }
105
185
 
106
186
  /**
@@ -111,8 +191,21 @@ function literalValue(raw: string): boolean {
111
191
  * parser is shared by every scanner and budgeted (see nativeScan.perf.test).
112
192
  */
113
193
  export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
194
+ return findInLines(hcl, null);
195
+ }
196
+
197
+ /**
198
+ * Every secret-named literal in a `.tfvars` file. A tfvars file is nothing but
199
+ * assignments, so every one is read, at any depth, under the file's own name.
200
+ */
201
+ export function findTfvarsSecrets(hcl: string, path: string): HardcodedSecret[] {
202
+ return findInLines(hcl, `tfvars input ${path}`);
203
+ }
204
+
205
+ /** `fileHolder` set: the whole file is one holder (a tfvars file), with no blocks. */
206
+ function findInLines(hcl: string, fileHolder: string | null): HardcodedSecret[] {
114
207
  const out: HardcodedSecret[] = [];
115
- let holder: string | null = null;
208
+ let holder: string | null = fileHolder;
116
209
  let heredoc: string | null = null;
117
210
  let inComment = false;
118
211
  const lines = hcl.split("\n");
@@ -132,12 +225,30 @@ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
132
225
  continue;
133
226
  }
134
227
  if (trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
135
- const header = HEADER.exec(line);
228
+ // a comment opened mid-line hides the lines after it; this line's code still counts
229
+ if (opensBlockComment(line)) inComment = true;
230
+ const header = fileHolder === null ? HEADER.exec(line) : null;
136
231
  if (header) {
137
232
  holder = `${header[1]}${(header[2] as string).trimEnd() ? ` ${(header[2] as string).trim()}` : ""}`;
233
+ // a block written on one line, `resource "a" "b" { password = "…" }`, carries its body
234
+ const rest = header[3] as string;
235
+ const opens = HEREDOC.exec(rest);
236
+ if (opens) heredoc = opens[1] as string;
237
+ if (!holder.startsWith("output ") && !holder.startsWith("terraform")) {
238
+ for (const pair of rest.matchAll(INLINE_PAIR)) {
239
+ const key = (pair[1] ?? pair[2]) as string;
240
+ const isVariable = holder.startsWith("variable ");
241
+ if (isVariable && key !== "default") continue;
242
+ const name = isVariable ? variableName(holder) : key;
243
+ if (isSecretName(name) && literalValue(name, pair[3] as string)) {
244
+ out.push({ name, holder, line: i + 1 });
245
+ }
246
+ }
247
+ }
248
+ if (closesOnLine(rest)) holder = null;
138
249
  continue;
139
250
  }
140
- if (line.startsWith("}")) {
251
+ if (fileHolder === null && line.startsWith("}")) {
141
252
  holder = null;
142
253
  continue;
143
254
  }
@@ -150,21 +261,21 @@ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
150
261
  const value = m[3] as string;
151
262
  if (holder.startsWith("variable ")) {
152
263
  // a variable's literal is its default, and the variable's name says what it holds
153
- const variable = /^variable\s+"?([\w-]+)"?/.exec(holder)?.[1] ?? "";
154
- if (name === "default" && isSecretName(variable) && literalValue(value)) {
264
+ const variable = variableName(holder);
265
+ if (name === "default" && isSecretName(variable) && literalValue(variable, value)) {
155
266
  out.push({ name: variable, holder, line: i + 1 });
156
267
  }
157
268
  continue;
158
269
  }
159
270
  if (holder.startsWith("output ") || holder.startsWith("terraform")) continue;
160
- if (isSecretName(name) && literalValue(value)) {
271
+ if (isSecretName(name) && literalValue(name, value)) {
161
272
  out.push({ name, holder, line: i + 1 });
162
273
  continue;
163
274
  }
164
275
  // an object written on one line holds its own pairs: `{ "DB_PASSWORD" = "…" }`
165
276
  for (const pair of value.matchAll(INLINE_PAIR)) {
166
277
  const key = (pair[1] ?? pair[2]) as string;
167
- if (isSecretName(key) && literalValue(pair[3] as string)) {
278
+ if (isSecretName(key) && literalValue(key, pair[3] as string)) {
168
279
  out.push({ name: key, holder, line: i + 1 });
169
280
  }
170
281
  }
@@ -172,11 +283,21 @@ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
172
283
  return out;
173
284
  }
174
285
 
175
- /** Scan an already-read tree into native concerns. Pure; no I/O of its own. */
176
- export function scanHardcodedSecrets(files: readonly SourceFile[]): Concern[] {
286
+ /**
287
+ * Scan an already-read tree into native concerns. Pure; no I/O of its own.
288
+ * `tfvarsFiles` are the `.tfvars` inputs, read as assignments rather than blocks.
289
+ */
290
+ export function scanHardcodedSecrets(
291
+ files: readonly SourceFile[],
292
+ tfvarsFiles: readonly SourceFile[] = [],
293
+ ): Concern[] {
177
294
  const concerns: Concern[] = [];
178
- for (const { path, hcl } of files) {
179
- for (const hit of findHardcodedSecrets(hcl)) {
295
+ const hits = [
296
+ ...files.map((f) => ({ path: f.path, found: findHardcodedSecrets(f.hcl) })),
297
+ ...tfvarsFiles.map((f) => ({ path: f.path, found: findTfvarsSecrets(f.hcl, f.path) })),
298
+ ];
299
+ for (const { path, found } of hits) {
300
+ for (const hit of found) {
180
301
  concerns.push(
181
302
  buildConcern({
182
303
  source: NATIVE_SOURCE,
@@ -131,7 +131,8 @@ export const NATIVE_RULE_SCANNERS: readonly NativeRuleScanner[] = [
131
131
  name: "hardcoded-secrets",
132
132
  rules: [HARDCODED_SECRET_ARGUMENT],
133
133
  // Per file: the argument's name and its literal are both on the line itself.
134
- scan: (ctx) => ({ concerns: scanHardcodedSecrets(ctx.files) }),
134
+ // The only rule that reads `.tfvars`: an input file holds values, not config.
135
+ scan: (ctx) => ({ concerns: scanHardcodedSecrets(ctx.files, ctx.tfvarsFiles) }),
135
136
  },
136
137
  {
137
138
  name: "write-only",
@@ -26,6 +26,7 @@ import {
26
26
  describeOversize,
27
27
  describeTruncation,
28
28
  readTfTree,
29
+ readTfvarsTree,
29
30
  readTofuTree,
30
31
  type SourceTree,
31
32
  } from "#app/mcp/terraform/tree";
@@ -74,6 +75,9 @@ export function scanNative(cwd: string, opts: NativeScanOptions = {}): ScannerOu
74
75
  // walk over a suffix most repos do not use at all, and threading it through
75
76
  // the context keeps the scanners uniform.
76
77
  tofuFiles: readTofuTree(cwd).files,
78
+ // likewise only for the hardcoded-secret rule: a value kept out of the `.tf`
79
+ // source is most often committed here instead
80
+ tfvarsFiles: readTfvarsTree(cwd).files,
77
81
  truncated: tree.truncated,
78
82
  repeatedLiteralThreshold: opts.repeatedLiteralThreshold ?? DEFAULT_REPEAT_THRESHOLD,
79
83
  });
@@ -31,14 +31,17 @@ import { describeMissingMoves, type PairAnalysis } from "#app/mcp/terraform/plan
31
31
  import {
32
32
  instanceKeysOf,
33
33
  isModuleAddress,
34
+ moduleHops,
34
35
  normaliseAddress,
35
36
  qualify,
36
37
  stripIndices,
38
+ stripModuleHops,
37
39
  } from "#app/mcp/terraform/refactor/addresses";
38
40
  import { type MovedBlock, moveId } from "#app/mcp/terraform/refactor/movedBlocks";
39
41
  import {
40
42
  describeMultiplicity,
41
43
  enumerateInstanceKeys,
44
+ type Multiplicity,
42
45
  type ResourceRef,
43
46
  sameMultiplicity,
44
47
  sameMultiplicityLevels,
@@ -384,10 +387,11 @@ function correspond(
384
387
  * than sending a correct refactor away for a plan it does not need.
385
388
  *
386
389
  * Abstains (null) on anything it cannot read exactly: a chain of more than one
387
- * repetition, an expression that is not a literal collection, or a declared block
388
- * that names no single instance. Extra blocks naming keys the collection does not
389
- * contain are NOT an error — a `moved` block whose source never existed is a
390
- * no-op, and a mistyped key shows up as the missing one it left behind.
390
+ * repetition, an expression that is not a literal collection, a declared block
391
+ * that names no single instance, or one whose key level cannot be located.
392
+ * Extra blocks naming keys the collection does not contain are NOT an error — a
393
+ * `moved` block whose source never existed is a no-op, and a mistyped key shows
394
+ * up as the missing one it left behind.
391
395
  */
392
396
  function uncoveredInstanceKeys(p: Pairing): string[] | null {
393
397
  const level = p.before.multiplicity;
@@ -400,25 +404,54 @@ function uncoveredInstanceKeys(p: Pairing): string[] | null {
400
404
  // is their exact spelling.
401
405
  const kind = level[0].kind;
402
406
  const written = (k: string) => (kind === "count" ? k : `"${k}"`);
407
+ const fromAt = level[0].at;
408
+ const toAt = p.after.multiplicity[0]?.at;
409
+ if (fromAt === undefined || toAt === undefined) return null;
403
410
  const declared = new Set<string>();
404
411
  for (const blk of p.blocks) {
412
+ // a chain hop or a module-level block names an address neither tree
413
+ // attributes, so where its repetition sits is unknown
414
+ if (
415
+ stripIndices(normaliseAddress(blk.from)) !== p.before.address ||
416
+ stripIndices(normaliseAddress(blk.to)) !== p.after.address
417
+ )
418
+ return null;
405
419
  // A relevelling repeats BOTH sides, at different levels: the instance is
406
420
  // `aws_s3_bucket.data["logs"]` before and `module.data["logs"].…` after, so a
407
- // block covers it only when each side names that same key. A key on one side
408
- // alone (`aws_s3_bucket.data["logs"]` → `module.data.aws_s3_bucket.this`)
409
- // points at an address the repetition no longer has, and Terraform plans it
410
- // as a destroy and a create.
421
+ // block covers it only when each side names that same key, at the level that
422
+ // side repeats. A key on one side alone (`aws_s3_bucket.data["logs"]` →
423
+ // `module.data.aws_s3_bucket.this`), or on the wrong level
424
+ // (`module.data.aws_s3_bucket.this["logs"]`), points at an address the
425
+ // repetition does not have, and Terraform plans it as a destroy and a create.
411
426
  const from = instanceKeysOf(blk.from);
412
427
  const to = instanceKeysOf(blk.to);
413
428
  // more than one key per side is a chain this proof does not read; no key on
414
429
  // either side names no single instance
415
430
  if (from.length > 1 || to.length > 1) return null;
416
431
  if (from.length === 0 && to.length === 0) return null;
417
- if (from.length === 1 && to.length === 1 && from[0] === to[0]) declared.add(from[0] as string);
432
+ if (from.length !== 1 || to.length !== 1 || from[0] !== to[0]) continue;
433
+ const fromPlaced = keyAtLevel(blk.from, fromAt);
434
+ const toPlaced = keyAtLevel(blk.to, toAt);
435
+ // a side on the wrong level covers nothing, whatever the other side says
436
+ if (fromPlaced === false || toPlaced === false) continue;
437
+ if (fromPlaced === null || toPlaced === null) return null;
438
+ declared.add(from[0] as string);
418
439
  }
419
440
  return expected.filter((k) => !declared.has(written(k)));
420
441
  }
421
442
 
443
+ /**
444
+ * Whether the one instance key `address` writes sits on the level `at` that
445
+ * repeats, or null when that level cannot be located: a module repetition on a
446
+ * path through several calls is on one of them, and the chain does not say which.
447
+ */
448
+ function keyAtLevel(address: string, at: NonNullable<Multiplicity["at"]>): boolean | null {
449
+ const onResource = instanceKeysOf(stripModuleHops(normaliseAddress(address))).length > 0;
450
+ if (at === "resource") return onResource;
451
+ if (onResource) return false;
452
+ return moduleHops(address).length === 1 ? true : null;
453
+ }
454
+
422
455
  /**
423
456
  * Compute the keys-free equivalence verdict from the before/after resource
424
457
  * address sets and the `moved {}` blocks present in the after tree. Validate/fmt