infraweaver 0.3.12 → 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.
- package/README.md +1 -1
- package/dist/agents/opencode.d.ts +6 -0
- package/dist/agents/quotaTokens.d.ts +6 -0
- package/dist/cli.mjs +554 -199
- package/dist/index.js +531 -176
- package/dist/internal.js +4 -4
- package/dist/mcp/git.d.ts +6 -4
- package/dist/mcp/localContext.d.ts +7 -0
- package/dist/mcp/reviewCommentLimit.d.ts +55 -5
- package/dist/mcp/reviewMarkers.d.ts +5 -0
- package/dist/mcp/staleFix.d.ts +36 -1
- package/dist/mcp/terraform/concernResult.d.ts +9 -0
- package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
- package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
- package/dist/mcp/terraform/refactorScope.d.ts +14 -2
- package/dist/mcp/terraform/ruleContext.d.ts +3 -0
- package/dist/mcp/terraform/tree.d.ts +5 -0
- package/dist/mcp/terraform/types.d.ts +2 -1
- package/dist/mcp/terraform/verifySites.d.ts +11 -3
- package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
- package/package.json +2 -2
- package/src/agents/opencode.ts +8 -1
- package/src/agents/quotaTokens.ts +7 -1
- package/src/mcp/git.ts +64 -12
- package/src/mcp/localContext.ts +6 -1
- package/src/mcp/moduleExtraction.ts +5 -4
- package/src/mcp/review.ts +52 -3
- package/src/mcp/reviewCommentLimit.ts +169 -26
- package/src/mcp/reviewMarkers.ts +11 -0
- package/src/mcp/reviewProvenance.ts +1 -1
- package/src/mcp/staleFix.ts +73 -2
- package/src/mcp/terraform/concernResult.ts +17 -10
- package/src/mcp/terraform/deltaSummary.ts +4 -0
- package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
- package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
- package/src/mcp/terraform/nativeScan.ts +4 -0
- package/src/mcp/terraform/refactor/equivalence.ts +42 -9
- package/src/mcp/terraform/refactorScope.ts +90 -21
- package/src/mcp/terraform/ruleContext.ts +4 -0
- package/src/mcp/terraform/scanDelta.ts +67 -26
- package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
- package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
- package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
- package/src/mcp/terraform/tools/readFindings.ts +2 -0
- package/src/mcp/terraform/tools/scan.ts +3 -0
- package/src/mcp/terraform/tree.ts +8 -0
- package/src/mcp/terraform/types.ts +2 -1
- package/src/mcp/terraform/verifySites.ts +23 -5
- package/src/modes/refactor.ts +1 -1
- package/src/modes/remediate-and-refactor.ts +1 -1
- package/src/modes/remediate.ts +1 -1
- package/src/phases/runAgentWithWatchdogs.ts +3 -2
|
@@ -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;
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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([
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
94
|
-
const HEADER = /^(resource|data|provider|module|locals|variable|output|terraform)\b([^{]*)\{
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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,
|
|
388
|
-
* that names no single instance
|
|
389
|
-
*
|
|
390
|
-
* no-op, and a mistyped key shows
|
|
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
|
|
408
|
-
// alone (`aws_s3_bucket.data["logs"]` →
|
|
409
|
-
//
|
|
410
|
-
//
|
|
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
|
|
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
|
|
@@ -12,19 +12,72 @@
|
|
|
12
12
|
* people call (terraform-aws-vpc, say). Moving its resources into a submodule is
|
|
13
13
|
* provably equivalent, and still churn for every consumer: their plans gain a
|
|
14
14
|
* wall of `moved` addresses for no change in what they get. There, extraction
|
|
15
|
-
* and normalisation candidates
|
|
16
|
-
* run's summary, never opened as a PR.
|
|
17
|
-
*
|
|
15
|
+
* and normalisation candidates in the module's own code come back marked
|
|
16
|
+
* `report_only` — named in the run's summary, never opened as a PR. A directory
|
|
17
|
+
* inside it that deploys (`envs/prod/` with its own provider or backend) is a
|
|
18
|
+
* configuration like any other, and its candidates stay actionable.
|
|
19
|
+
* Consolidating roots that duplicate each other is unaffected: that removes real
|
|
20
|
+
* duplication, not style.
|
|
18
21
|
*/
|
|
19
|
-
import {
|
|
22
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
20
23
|
import { join } from "node:path";
|
|
24
|
+
import { BLOCK_LABEL, parseBlocks } from "#app/mcp/terraform/hcl";
|
|
21
25
|
|
|
22
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* true for a path inside an `examples/` directory at any depth, or an `example/`
|
|
28
|
+
* one at the repository root. The registry's convention is `examples/`; the
|
|
29
|
+
* singular is accepted only where that convention puts it, because a module
|
|
30
|
+
* NAMED `example` (`modules/example/`) is code someone calls.
|
|
31
|
+
*/
|
|
23
32
|
export function isExamplePath(path: string): boolean {
|
|
24
|
-
return /(?:^|\/)examples
|
|
33
|
+
return /(?:^|\/)examples\/|^example\//i.test(path.replace(/\\/g, "/"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PROVIDER_OR_BACKEND = new RegExp(
|
|
37
|
+
String.raw`(?:^|\n)\s*(?:provider|backend)\s+${BLOCK_LABEL}\s*\{`,
|
|
38
|
+
);
|
|
39
|
+
const CLOUD_BLOCK = /(?:^|\n)\s*cloud\s*\{/;
|
|
40
|
+
|
|
41
|
+
/** whether one file's text configures a provider, a backend or HCP Terraform. */
|
|
42
|
+
function configuresDeployment(name: string, text: string): boolean {
|
|
43
|
+
if (name.endsWith(".tf.json")) {
|
|
44
|
+
let doc: unknown;
|
|
45
|
+
try {
|
|
46
|
+
doc = JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (typeof doc !== "object" || doc === null) return false;
|
|
51
|
+
const { provider, terraform } = doc as { provider?: unknown; terraform?: unknown };
|
|
52
|
+
if (provider !== undefined) return true;
|
|
53
|
+
// `terraform` is an object, or an array of them, in the JSON syntax
|
|
54
|
+
return [terraform]
|
|
55
|
+
.flat()
|
|
56
|
+
.some((t) => typeof t === "object" && t !== null && ("backend" in t || "cloud" in t));
|
|
57
|
+
}
|
|
58
|
+
if (PROVIDER_OR_BACKEND.test(text)) return true;
|
|
59
|
+
// `cloud` is a block name other schemas use too; only the settings block's counts
|
|
60
|
+
return parseBlocks(text, ["terraform"]).some((b) => CLOUD_BLOCK.test(b.body));
|
|
25
61
|
}
|
|
26
62
|
|
|
27
|
-
const
|
|
63
|
+
const isTerraformFile = (name: string) => name.endsWith(".tf") || name.endsWith(".tf.json");
|
|
64
|
+
|
|
65
|
+
/** whether the Terraform directly in `dir` is a root someone deploys from. */
|
|
66
|
+
function deploysFrom(dir: string): boolean {
|
|
67
|
+
let names: string[];
|
|
68
|
+
try {
|
|
69
|
+
names = readdirSync(dir).filter(isTerraformFile);
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return names.some((name) => {
|
|
74
|
+
try {
|
|
75
|
+
return configuresDeployment(name, readFileSync(join(dir, name), "utf8"));
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
28
81
|
|
|
29
82
|
/** whether some directory under `dir` (to a small depth) holds a `.tf` file. */
|
|
30
83
|
function holdsTerraform(dir: string, depth: number): boolean {
|
|
@@ -53,23 +106,14 @@ function holdsTerraform(dir: string, depth: number): boolean {
|
|
|
53
106
|
* deployment, or null when it does not.
|
|
54
107
|
*/
|
|
55
108
|
export function libraryRepoReason(cwd: string): string | null {
|
|
56
|
-
let
|
|
109
|
+
let rootEntries: string[];
|
|
57
110
|
try {
|
|
58
|
-
|
|
111
|
+
rootEntries = readdirSync(cwd);
|
|
59
112
|
} catch {
|
|
60
113
|
return null;
|
|
61
114
|
}
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
let text: string;
|
|
65
|
-
try {
|
|
66
|
-
text = readFileSync(join(cwd, f), "utf8");
|
|
67
|
-
} catch {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (PROVIDER_OR_BACKEND.test(text)) return null;
|
|
71
|
-
}
|
|
72
|
-
const examples = ["examples", "example"].find((d) => existsSync(join(cwd, d)));
|
|
115
|
+
if (!rootEntries.some(isTerraformFile) || deploysFrom(cwd)) return null;
|
|
116
|
+
const examples = rootEntries.find((d) => /^examples?$/i.test(d));
|
|
73
117
|
if (examples === undefined || !holdsTerraform(join(cwd, examples), 2)) return null;
|
|
74
118
|
return (
|
|
75
119
|
`the repository root configures no provider and no backend, and ships \`${examples}/\`: it is a ` +
|
|
@@ -77,7 +121,32 @@ export function libraryRepoReason(cwd: string): string | null {
|
|
|
77
121
|
);
|
|
78
122
|
}
|
|
79
123
|
|
|
80
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* For a library repository, why a candidate in `file` (repo-relative) is
|
|
126
|
+
* reported rather than restructured — or null when the file belongs to a
|
|
127
|
+
* directory, or sits under one, that deploys on its own. Outside a library,
|
|
128
|
+
* always null.
|
|
129
|
+
*/
|
|
130
|
+
export function libraryScope(cwd: string): (file: string) => string | null {
|
|
131
|
+
const reason = libraryRepoReason(cwd);
|
|
132
|
+
if (reason === null) return () => null;
|
|
133
|
+
const deploys = new Map<string, boolean>();
|
|
134
|
+
return (file) => {
|
|
135
|
+
const dirs = file.replace(/\\/g, "/").split("/").slice(0, -1);
|
|
136
|
+
for (let i = dirs.length; i > 0; i--) {
|
|
137
|
+
const dir = dirs.slice(0, i).join("/");
|
|
138
|
+
let d = deploys.get(dir);
|
|
139
|
+
if (d === undefined) {
|
|
140
|
+
d = deploysFrom(join(cwd, dir));
|
|
141
|
+
deploys.set(dir, d);
|
|
142
|
+
}
|
|
143
|
+
if (d) return null;
|
|
144
|
+
}
|
|
145
|
+
return reason;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** what a detector tool adds to a candidate in a library's own code. */
|
|
81
150
|
export function reportOnly(reason: string | null): {
|
|
82
151
|
report_only?: true;
|
|
83
152
|
report_only_reason?: string;
|
|
@@ -46,6 +46,8 @@ export interface RuleContext {
|
|
|
46
46
|
readonly files: readonly SourceFile[];
|
|
47
47
|
/** the `.tofu` half. Only the dialect rule needs it; empty on a repo with none. */
|
|
48
48
|
readonly tofuFiles: readonly SourceFile[];
|
|
49
|
+
/** the `.tfvars` inputs. Only the hardcoded-secret rule reads them; empty on a repo with none. */
|
|
50
|
+
readonly tfvarsFiles: readonly SourceFile[];
|
|
49
51
|
/** non-null when the walk was bounded before it finished — see [[tree]]. */
|
|
50
52
|
readonly truncated: TreeTruncation;
|
|
51
53
|
/** how many times a literal must repeat in one directory before it is reported. */
|
|
@@ -80,6 +82,7 @@ export interface RuleContextInput {
|
|
|
80
82
|
cwd: string;
|
|
81
83
|
files: readonly SourceFile[];
|
|
82
84
|
tofuFiles?: readonly SourceFile[];
|
|
85
|
+
tfvarsFiles?: readonly SourceFile[];
|
|
83
86
|
truncated?: TreeTruncation;
|
|
84
87
|
repeatedLiteralThreshold: number;
|
|
85
88
|
}
|
|
@@ -89,6 +92,7 @@ export function createRuleContext(input: RuleContextInput): RuleContext {
|
|
|
89
92
|
cwd: input.cwd,
|
|
90
93
|
files: input.files,
|
|
91
94
|
tofuFiles: input.tofuFiles ?? [],
|
|
95
|
+
tfvarsFiles: input.tfvarsFiles ?? [],
|
|
92
96
|
truncated: input.truncated ?? null,
|
|
93
97
|
repeatedLiteralThreshold: input.repeatedLiteralThreshold,
|
|
94
98
|
};
|