infraweaver 0.3.9 → 0.3.11
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/quotaTokens.d.ts +8 -0
- package/dist/cli.mjs +1844 -1601
- package/dist/index.js +422 -182
- package/dist/internal.js +6 -4
- package/dist/mcp/crosswalk-digest.d.ts +1 -1
- package/dist/mcp/git.d.ts +19 -3
- package/dist/mcp/localContext.d.ts +9 -1
- package/dist/mcp/shell.d.ts +3 -3
- package/dist/mcp/staleFix.d.ts +13 -1
- package/dist/mcp/terraform/hardcodedSecrets.d.ts +49 -0
- package/dist/mcp/terraform/nativeRules.d.ts +1 -0
- package/dist/mcp/terraform/refactorScope.d.ts +12 -0
- package/dist/mcp/terraform/tools/scan.d.ts +1 -0
- package/dist/mcp/terraform/types.d.ts +3 -0
- package/dist/utils/config/payload.d.ts +2 -0
- package/package.json +1 -1
- package/src/agents/claude.ts +2 -1
- package/src/agents/opencode.ts +2 -1
- package/src/agents/quotaTokens.ts +25 -0
- package/src/mcp/crosswalk-digest.ts +1 -1
- package/src/mcp/crosswalk.ts +2 -0
- package/src/mcp/git.ts +44 -3
- package/src/mcp/localContext.ts +8 -0
- package/src/mcp/localServer.ts +3 -0
- package/src/mcp/moduleExtraction.ts +4 -1
- package/src/mcp/review.ts +3 -0
- package/src/mcp/shell.ts +2 -2
- package/src/mcp/staleFix.ts +21 -2
- package/src/mcp/terraform/concernResult.ts +24 -4
- package/src/mcp/terraform/hardcodedSecrets.ts +202 -0
- package/src/mcp/terraform/nativeRuleScanners.ts +8 -0
- package/src/mcp/terraform/nativeRules.ts +14 -0
- package/src/mcp/terraform/refactorScope.ts +86 -0
- package/src/mcp/terraform/standardsReport.ts +2 -0
- package/src/mcp/terraform/tools/consolidationCandidates.ts +5 -1
- package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -1
- package/src/mcp/terraform/types.ts +3 -0
- package/src/modes/refactor.ts +2 -0
- package/src/modes/refresh-remediation.ts +1 -1
- package/src/modes/remediate-and-refactor.ts +1 -1
- package/src/modes/remediate.ts +2 -2
- package/src/utils/config/payload.ts +4 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential written into the configuration as a string literal, by the
|
|
3
|
+
* name of the argument that holds it.
|
|
4
|
+
*
|
|
5
|
+
* WHY BY NAME. The secret scanners look at the VALUE — a known key format, or a
|
|
6
|
+
* string with enough entropy — so a hand-made password (`"cg-flagshop-Pr0d!…"`)
|
|
7
|
+
* slips past all of them. Terraform says more than a text scanner can see: the
|
|
8
|
+
* value sits in an argument, a map key or a variable whose name says what it
|
|
9
|
+
* holds. A literal in `password`, `client_secret` or a Lambda environment
|
|
10
|
+
* variable called `auth` is in the repository whatever it looks like.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS READ. Every `name = "…"` line inside a `resource`, `data`,
|
|
13
|
+
* `provider`, `module` or `locals` block, at any depth, so
|
|
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.
|
|
19
|
+
*
|
|
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.
|
|
24
|
+
*
|
|
25
|
+
* Arguments the provider can take write-only (`aws_db_instance.password`) may
|
|
26
|
+
* also be reported by `secret-persisted-to-state`, which says something else:
|
|
27
|
+
* that the value reaches state. This one says it is in git.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { isStringLiteral, stripTrailingComment } from "#app/mcp/terraform/hcl";
|
|
31
|
+
import { HARDCODED_SECRET_ARGUMENT, NATIVE_SOURCE } from "#app/mcp/terraform/nativeRules";
|
|
32
|
+
import type { SourceFile } from "#app/mcp/terraform/tree";
|
|
33
|
+
import { buildConcern, type Concern } from "#app/mcp/terraform/types";
|
|
34
|
+
|
|
35
|
+
/** a name's last word, alone, says it holds a secret. */
|
|
36
|
+
const SECRET_WORDS = new Set([
|
|
37
|
+
"password",
|
|
38
|
+
"passwd",
|
|
39
|
+
"pwd",
|
|
40
|
+
"passphrase",
|
|
41
|
+
"secret",
|
|
42
|
+
"token",
|
|
43
|
+
"auth",
|
|
44
|
+
"apikey",
|
|
45
|
+
"credential",
|
|
46
|
+
"credentials",
|
|
47
|
+
]);
|
|
48
|
+
/** `<word>_key` names that hold a secret (`api_key`, `secret_key`, …). */
|
|
49
|
+
const SECRET_KEY_PREFIXES = new Set(["api", "secret", "private", "signing"]);
|
|
50
|
+
/** `<word>_token` names that are identifiers, not credentials. */
|
|
51
|
+
const NOT_SECRET_TOKENS = new Set(["creation", "idempotency", "client", "request", "continuation"]);
|
|
52
|
+
|
|
53
|
+
function words(name: string): string[] {
|
|
54
|
+
return name
|
|
55
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.split(/[_-]+/)
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when an argument, map key or variable called `name` holds a secret. */
|
|
62
|
+
export function isSecretName(name: string): boolean {
|
|
63
|
+
const w = words(name);
|
|
64
|
+
const last = w.at(-1);
|
|
65
|
+
const prev = w.at(-2);
|
|
66
|
+
if (last === undefined) return false;
|
|
67
|
+
if (last === "key") return prev !== undefined && SECRET_KEY_PREFIXES.has(prev);
|
|
68
|
+
if (last === "token" && prev !== undefined && NOT_SECRET_TOKENS.has(prev)) return false;
|
|
69
|
+
return SECRET_WORDS.has(last);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A literal that says it is not a real value: empty, masked, or `<…>`. */
|
|
73
|
+
function isPlaceholder(inner: string): boolean {
|
|
74
|
+
return (
|
|
75
|
+
inner.trim() === "" ||
|
|
76
|
+
/^[*xX.]+$/.test(inner) ||
|
|
77
|
+
/^<[^>]*>$/.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) ||
|
|
80
|
+
// a token's lifetime unit: `token_validity_units { access_token = "hours" }`
|
|
81
|
+
/^(?:seconds|minutes|hours|days)$/.test(inner)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface HardcodedSecret {
|
|
86
|
+
/** the argument, map key or variable name. */
|
|
87
|
+
name: string;
|
|
88
|
+
/** what holds it: `resource "aws_lambda_function" "x"`, `variable "db_password"`, … */
|
|
89
|
+
holder: string;
|
|
90
|
+
line: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
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*$/;
|
|
95
|
+
/** `name = value`, the name bare or quoted */
|
|
96
|
+
const ASSIGNMENT = /^\s*(?:"([A-Za-z_][\w-]*)"|([A-Za-z_][\w-]*))\s*=(?!=)\s*(.*)$/;
|
|
97
|
+
const HEREDOC = /<<-?([A-Za-z_]\w*)\s*$/;
|
|
98
|
+
/** a `key = "string"` pair inside a one-line object */
|
|
99
|
+
const INLINE_PAIR = /(?:"([A-Za-z_][\w-]*)"|\b([A-Za-z_][\w-]*))\s*=(?!=)\s*("(?:[^"\\]|\\.)*")/g;
|
|
100
|
+
|
|
101
|
+
function literalValue(raw: string): boolean {
|
|
102
|
+
const value = stripTrailingComment(raw).replace(/,$/, "").trim();
|
|
103
|
+
return isStringLiteral(value) && !isPlaceholder(value.slice(1, -1));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every secret-named literal in one file. Pure.
|
|
108
|
+
*
|
|
109
|
+
* One pass over the lines rather than a block parse: every assignment line is
|
|
110
|
+
* the same question whatever block it sits in, and the native pack's block
|
|
111
|
+
* parser is shared by every scanner and budgeted (see nativeScan.perf.test).
|
|
112
|
+
*/
|
|
113
|
+
export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
|
|
114
|
+
const out: HardcodedSecret[] = [];
|
|
115
|
+
let holder: string | null = null;
|
|
116
|
+
let heredoc: string | null = null;
|
|
117
|
+
let inComment = false;
|
|
118
|
+
const lines = hcl.split("\n");
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const line = (lines[i] as string).replace(/\r$/, "");
|
|
121
|
+
if (heredoc !== null) {
|
|
122
|
+
if (line.trim() === heredoc) heredoc = null;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (inComment) {
|
|
126
|
+
if (line.includes("*/")) inComment = false;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const trimmed = line.trimStart();
|
|
130
|
+
if (trimmed.startsWith("/*")) {
|
|
131
|
+
inComment = !trimmed.includes("*/");
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
|
|
135
|
+
const header = HEADER.exec(line);
|
|
136
|
+
if (header) {
|
|
137
|
+
holder = `${header[1]}${(header[2] as string).trimEnd() ? ` ${(header[2] as string).trim()}` : ""}`;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (line.startsWith("}")) {
|
|
141
|
+
holder = null;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const opens = HEREDOC.exec(line);
|
|
145
|
+
if (opens) heredoc = opens[1] as string;
|
|
146
|
+
if (holder === null || opens) continue;
|
|
147
|
+
const m = ASSIGNMENT.exec(line);
|
|
148
|
+
if (!m) continue;
|
|
149
|
+
const name = (m[1] ?? m[2]) as string;
|
|
150
|
+
const value = m[3] as string;
|
|
151
|
+
if (holder.startsWith("variable ")) {
|
|
152
|
+
// 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)) {
|
|
155
|
+
out.push({ name: variable, holder, line: i + 1 });
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (holder.startsWith("output ") || holder.startsWith("terraform")) continue;
|
|
160
|
+
if (isSecretName(name) && literalValue(value)) {
|
|
161
|
+
out.push({ name, holder, line: i + 1 });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// an object written on one line holds its own pairs: `{ "DB_PASSWORD" = "…" }`
|
|
165
|
+
for (const pair of value.matchAll(INLINE_PAIR)) {
|
|
166
|
+
const key = (pair[1] ?? pair[2]) as string;
|
|
167
|
+
if (isSecretName(key) && literalValue(pair[3] as string)) {
|
|
168
|
+
out.push({ name: key, holder, line: i + 1 });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Scan an already-read tree into native concerns. Pure; no I/O of its own. */
|
|
176
|
+
export function scanHardcodedSecrets(files: readonly SourceFile[]): Concern[] {
|
|
177
|
+
const concerns: Concern[] = [];
|
|
178
|
+
for (const { path, hcl } of files) {
|
|
179
|
+
for (const hit of findHardcodedSecrets(hcl)) {
|
|
180
|
+
concerns.push(
|
|
181
|
+
buildConcern({
|
|
182
|
+
source: NATIVE_SOURCE,
|
|
183
|
+
rule: HARDCODED_SECRET_ARGUMENT,
|
|
184
|
+
file: path,
|
|
185
|
+
line: hit.line,
|
|
186
|
+
// a credential in the repository is readable by everyone who can read
|
|
187
|
+
// it, and stays in the history after it is edited out
|
|
188
|
+
severity: "high",
|
|
189
|
+
category: "security",
|
|
190
|
+
evidence:
|
|
191
|
+
`\`${hit.name}\` in \`${hit.holder}\` is a string literal, so the credential it holds is ` +
|
|
192
|
+
"committed to the repository (value not quoted here)",
|
|
193
|
+
remediation_hint:
|
|
194
|
+
"Rotate the credential first — it stays in the branch history after the line is changed. " +
|
|
195
|
+
"Then pass it in at run time: a `sensitive` variable with no default, or a read from a " +
|
|
196
|
+
"secret store (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager).",
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return concerns;
|
|
202
|
+
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { scanDialect } from "#app/mcp/terraform/dialect";
|
|
28
28
|
import { scanGovernanceFile } from "#app/mcp/terraform/governance";
|
|
29
|
+
import { scanHardcodedSecrets } from "#app/mcp/terraform/hardcodedSecrets";
|
|
29
30
|
import { scanInterfaceHygiene } from "#app/mcp/terraform/moduleInterfaceRules";
|
|
30
31
|
import { scanUnpinnedModules } from "#app/mcp/terraform/moduleVersionConstraints";
|
|
31
32
|
import {
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
DEPRECATED_S3_BUCKET_ARGUMENT,
|
|
35
36
|
DEPRECATED_STATE_LOCKING,
|
|
36
37
|
DYNAMIC_DESTROY_PROTECTION,
|
|
38
|
+
HARDCODED_SECRET_ARGUMENT,
|
|
37
39
|
IMPERATIVE_ACTION_TRIGGER,
|
|
38
40
|
LEGACY_BLOCK_ASSIGNMENT,
|
|
39
41
|
LEGACY_INTERPOLATION,
|
|
@@ -125,6 +127,12 @@ export const NATIVE_RULE_SCANNERS: readonly NativeRuleScanner[] = [
|
|
|
125
127
|
// one. See [[stateFiles]].
|
|
126
128
|
scan: (ctx) => scanCommittedStateFiles(ctx.cwd),
|
|
127
129
|
},
|
|
130
|
+
{
|
|
131
|
+
name: "hardcoded-secrets",
|
|
132
|
+
rules: [HARDCODED_SECRET_ARGUMENT],
|
|
133
|
+
// Per file: the argument's name and its literal are both on the line itself.
|
|
134
|
+
scan: (ctx) => ({ concerns: scanHardcodedSecrets(ctx.files) }),
|
|
135
|
+
},
|
|
128
136
|
{
|
|
129
137
|
name: "write-only",
|
|
130
138
|
rules: [SECRET_PERSISTED_TO_STATE],
|
|
@@ -59,6 +59,7 @@ export interface NativeRule {
|
|
|
59
59
|
|
|
60
60
|
export const SECRET_PERSISTED_TO_STATE = "secret-persisted-to-state";
|
|
61
61
|
export const COMMITTED_STATE_FILE = "committed-state-file";
|
|
62
|
+
export const HARDCODED_SECRET_ARGUMENT = "hardcoded-secret-argument";
|
|
62
63
|
export const IMPERATIVE_ACTION_TRIGGER = "imperative-action-trigger";
|
|
63
64
|
export const DYNAMIC_DESTROY_PROTECTION = "dynamic-destroy-protection";
|
|
64
65
|
export const DEPRECATED_STATE_LOCKING = "deprecated-state-locking";
|
|
@@ -103,6 +104,19 @@ export const NATIVE_RULES: readonly NativeRule[] = [
|
|
|
103
104
|
"that the state contains a live credential, or that anyone has read it — the file is never opened; " +
|
|
104
105
|
"the finding is that state is under version control, and what it recorded is for the owner to say",
|
|
105
106
|
},
|
|
107
|
+
{
|
|
108
|
+
id: HARDCODED_SECRET_ARGUMENT,
|
|
109
|
+
title: "Credential written into the configuration as a literal",
|
|
110
|
+
band: "verified",
|
|
111
|
+
category: "security",
|
|
112
|
+
asserts:
|
|
113
|
+
"an argument, map key or variable whose name says it holds a credential (`password`, `token`, " +
|
|
114
|
+
"`client_secret`, `api_key`, …) is assigned a string literal, so the value is in the repository " +
|
|
115
|
+
"and in its history",
|
|
116
|
+
doesNotAssert:
|
|
117
|
+
"that the value is a live credential — it may be a test value; the finding is that a credential " +
|
|
118
|
+
"slot is filled from the code rather than at run time",
|
|
119
|
+
},
|
|
106
120
|
{
|
|
107
121
|
id: IMPERATIVE_ACTION_TRIGGER,
|
|
108
122
|
title: "Imperative side-effect invoked from declarative IaC (action / action_trigger)",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Refactor run may propose, beyond what the detectors find.
|
|
3
|
+
*
|
|
4
|
+
* EXAMPLES ARE NOT REFACTORED BY DEFAULT. An `examples/` root is documentation
|
|
5
|
+
* that happens to plan: its resources exist to show a caller how to use the
|
|
6
|
+
* module, so they repeat on purpose, and a PR that consolidates or extracts them
|
|
7
|
+
* rewrites the docs to save nobody anything. The `refactor_examples` input opts
|
|
8
|
+
* back in.
|
|
9
|
+
*
|
|
10
|
+
* A REUSABLE MODULE IS REPORTED, NOT RESTRUCTURED. A repository whose root
|
|
11
|
+
* configures no provider and no backend, and ships `examples/`, is a module other
|
|
12
|
+
* people call (terraform-aws-vpc, say). Moving its resources into a submodule is
|
|
13
|
+
* provably equivalent, and still churn for every consumer: their plans gain a
|
|
14
|
+
* wall of `moved` addresses for no change in what they get. There, extraction
|
|
15
|
+
* and normalisation candidates come back marked `report_only` — named in the
|
|
16
|
+
* run's summary, never opened as a PR. Consolidating roots that duplicate each
|
|
17
|
+
* other is unaffected: that removes real duplication, not style.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
/** true for a path inside an `examples/` (or `example/`) directory, at any depth. */
|
|
23
|
+
export function isExamplePath(path: string): boolean {
|
|
24
|
+
return /(?:^|\/)examples?\//.test(path.replace(/\\/g, "/"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PROVIDER_OR_BACKEND = /^\s*(?:provider\s+"|backend\s+")/m;
|
|
28
|
+
|
|
29
|
+
/** whether some directory under `dir` (to a small depth) holds a `.tf` file. */
|
|
30
|
+
function holdsTerraform(dir: string, depth: number): boolean {
|
|
31
|
+
let entries: string[];
|
|
32
|
+
try {
|
|
33
|
+
entries = readdirSync(dir);
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
for (const name of entries) {
|
|
38
|
+
const path = join(dir, name);
|
|
39
|
+
if (name.endsWith(".tf")) return true;
|
|
40
|
+
if (
|
|
41
|
+
depth > 0 &&
|
|
42
|
+
!name.startsWith(".") &&
|
|
43
|
+
statSync(path, { throwIfNoEntry: false })?.isDirectory()
|
|
44
|
+
) {
|
|
45
|
+
if (holdsTerraform(path, depth - 1)) return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Why the repository at `cwd` reads as a reusable module rather than a
|
|
53
|
+
* deployment, or null when it does not.
|
|
54
|
+
*/
|
|
55
|
+
export function libraryRepoReason(cwd: string): string | null {
|
|
56
|
+
let rootFiles: string[];
|
|
57
|
+
try {
|
|
58
|
+
rootFiles = readdirSync(cwd).filter((f) => f.endsWith(".tf"));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (rootFiles.length === 0) return null;
|
|
63
|
+
for (const f of rootFiles) {
|
|
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)));
|
|
73
|
+
if (examples === undefined || !holdsTerraform(join(cwd, examples), 2)) return null;
|
|
74
|
+
return (
|
|
75
|
+
`the repository root configures no provider and no backend, and ships \`${examples}/\`: it is a ` +
|
|
76
|
+
"module other configurations call, so restructuring it is churn for every caller"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** what a detector tool adds to its result when the repository is a library. */
|
|
81
|
+
export function reportOnly(reason: string | null): {
|
|
82
|
+
report_only?: true;
|
|
83
|
+
report_only_reason?: string;
|
|
84
|
+
} {
|
|
85
|
+
return reason === null ? {} : { report_only: true, report_only_reason: reason };
|
|
86
|
+
}
|
|
@@ -364,6 +364,8 @@ const SUBJECT: Record<string, RegExp> = {
|
|
|
364
364
|
// rule's silence a real statement. Whether git could answer is handled one
|
|
365
365
|
// level up by the blind set: a run that could not ask is never conforming.
|
|
366
366
|
"committed-state-file": ANY_BLOCK,
|
|
367
|
+
// any block may hold a credential argument, a map of them, or a variable default
|
|
368
|
+
"hardcoded-secret-argument": ANY_BLOCK,
|
|
367
369
|
"deprecated-s3-bucket-argument": /(?:^|\n)\s*resource\s+"?aws_s3_bucket(?:"|\s)/,
|
|
368
370
|
"superseded-resource-type":
|
|
369
371
|
/(?:^|\n)\s*(?:resource\s+"?(?:aws_launch_configuration|aws_elb)(?:"|\s)|data\s+"?template_file(?:"|\s))/,
|
|
@@ -3,6 +3,7 @@ import type { LocalToolContext } from "#app/mcp/localContext";
|
|
|
3
3
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
4
4
|
import { findConsolidationGroups } from "#app/mcp/terraform/consolidation";
|
|
5
5
|
import { readWorkingTree } from "#app/mcp/terraform/refactor/workspace";
|
|
6
|
+
import { isExamplePath } from "#app/mcp/terraform/refactorScope";
|
|
6
7
|
import { log } from "#app/utils/cli";
|
|
7
8
|
|
|
8
9
|
const ConsolidationCandidatesParams = type({});
|
|
@@ -37,7 +38,10 @@ export function ConsolidationCandidatesTool(ctx: LocalToolContext) {
|
|
|
37
38
|
parameters: ConsolidationCandidatesParams,
|
|
38
39
|
execute: execute(async () => {
|
|
39
40
|
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
40
|
-
const
|
|
41
|
+
const tree = readWorkingTree(cwd);
|
|
42
|
+
const result = findConsolidationGroups(
|
|
43
|
+
ctx.payload.refactorExamples ? tree : tree.filter((f) => !isExamplePath(f.path)),
|
|
44
|
+
);
|
|
41
45
|
log.info(
|
|
42
46
|
`» terraform_consolidation_candidates: ${result.groups.length} group(s), ` +
|
|
43
47
|
`${result.near_groups.length} near-group(s), ${result.declined_groups.length} declined`,
|
|
@@ -2,6 +2,7 @@ import { type } from "arktype";
|
|
|
2
2
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
3
3
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
4
4
|
import { findNormalizationCandidates } from "#app/mcp/terraform/normalization";
|
|
5
|
+
import { isExamplePath, libraryRepoReason, reportOnly } from "#app/mcp/terraform/refactorScope";
|
|
5
6
|
import { log } from "#app/utils/cli";
|
|
6
7
|
|
|
7
8
|
// --- the tool ----------------------------------------------------------------
|
|
@@ -28,12 +29,15 @@ export function NormalizationCandidatesTool(ctx: LocalToolContext) {
|
|
|
28
29
|
parameters: NormalizationCandidatesParams,
|
|
29
30
|
execute: execute(async () => {
|
|
30
31
|
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
31
|
-
const files = findNormalizationCandidates(cwd)
|
|
32
|
+
const files = findNormalizationCandidates(cwd).filter(
|
|
33
|
+
(f) => ctx.payload.refactorExamples || !isExamplePath(f.file),
|
|
34
|
+
);
|
|
32
35
|
const total = files.reduce((n, f) => n + f.count, 0);
|
|
33
36
|
log.info(
|
|
34
37
|
`» terraform_normalization_candidates: ${total} site(s) across ${files.length} file(s)`,
|
|
35
38
|
);
|
|
36
39
|
return toolOk({
|
|
40
|
+
...reportOnly(libraryRepoReason(cwd)),
|
|
37
41
|
file_count: files.length,
|
|
38
42
|
total,
|
|
39
43
|
files,
|
|
@@ -398,6 +398,9 @@ export interface ConcernGroup {
|
|
|
398
398
|
/** stable id — the remediation branch/PR key (`remediate/<id>`). Derived from
|
|
399
399
|
* the file (by-file grouping) or the rule (by-rule grouping). */
|
|
400
400
|
id: string;
|
|
401
|
+
/** the branch its fix is pushed to: `remediate/<id>`, plus `--<base>` when the
|
|
402
|
+
* PR targets a branch other than the repository default. */
|
|
403
|
+
branch?: string;
|
|
401
404
|
/** the group's primary file (by-file) or a human label like "3 files"
|
|
402
405
|
* (by-rule); `files` carries the full list for by-rule groups. */
|
|
403
406
|
file: string;
|
package/src/modes/refactor.ts
CHANGED
|
@@ -18,6 +18,8 @@ This mode standardises STRUCTURE while preserving BEHAVIOUR — the equivalence
|
|
|
18
18
|
- \`${t("terraform_normalization_candidates")}\` → in-place idiomatic cleanups that change SYNTAX, not behaviour (redundant whole-string \`"\${expr}"\` interpolation; legacy HCL0.11 map-argument block syntax like \`vars {\` on a \`template_file\`). These relocate NOTHING — zero \`moved {}\` blocks — and are proven by the same equivalence check.
|
|
19
19
|
- \`${t("terraform_consolidation_candidates")}\` → the SAME resource shape declared in several root directories (\`env/dev\` + \`env/staging\` + \`env/prod\`), with the single parameterised module they could all call already derived: which attributes are identical (module content) and which differ (per-environment inputs). A member root of a group will ALSO show up as a zero-candidate cluster in \`${t("module_extraction_candidates")}\` — prefer the consolidation, which does that work once for every environment instead of once per environment. Its \`near_groups\` are the opposite case: roots recognisably the same stack that DIFFER (one carries a resource the other does not), where folding them together is a design decision this mode has no basis for. Do NOT extract a near-group root's shared resources into a module on its own either — a module one twin calls and the other does not widens exactly the drift the near-group reports. Leave those roots alone and name the near-group and its \`differences\` in your report.
|
|
20
20
|
|
|
21
|
+
**A result marked \`report_only\` is an observation, not work.** The repository is a module other configurations call (\`report_only_reason\` says why), and restructuring it is churn for every caller: name those candidates in your \`${t("report_progress")}\` summary and open no PR for them. \`examples/\` roots are left out of every candidate list unless the operator set \`refactor_examples\`.
|
|
22
|
+
|
|
21
23
|
**Before picking, check the interface.** \`${t("module_extraction_candidates")}\` reports \`unmapped_attributes\` per candidate and a top-level \`partial_interfaces\` list: arguments the raw resources SET that the target module does not. Adopting such a module DROPS them silently — and the equivalence check cannot catch it, because once the argument is gone it is absent from both sides. Two of these are unrecoverable rather than merely wrong (\`object_lock_enabled\` cannot be set after creation; \`force_destroy\` changes deletion semantics). So: **decline the candidate, or extend the module to expose the argument and say so in the PR body.** Deleting the argument to make the shapes match is never the fix. A candidate whose \`unmapped_attributes\` is \`null\` was NOT checked — the module is external and its code is not in this repo — which is not the same as clean.
|
|
22
24
|
|
|
23
25
|
Pick the highest-value refactor across ALL THREE detectors. Only when they ALL come back empty — or all that remains is a cluster in a near-group root — is there genuinely nothing to do: call \`${t("report_progress")}\` with an ACCURATE message — e.g. "No behaviour-preserving refactor found: no extraction clusters and no idiomatic-normalisation candidates." — and **stop**. Two anti-patterns to avoid in that message: (i) never call a repo "already idiomatic" while normalisation candidates remain; (ii) when what's left is behaviour-CHANGING modernisation — a deprecated-resource swap (\`aws_launch_configuration\`→\`aws_launch_template\`, \`aws_elb\`→\`aws_lb\`, \`template_file\`→\`templatefile()\`) — do NOT report "nothing to refactor": that is real work, but it ALTERS the resource set, so it belongs to Remediate. Say so explicitly rather than implying the code is clean.
|
|
@@ -26,7 +26,7 @@ This mode keeps already-open ${PRODUCT_NAME} remediation PRs healthy. A remediat
|
|
|
26
26
|
- **resolved-on-base? → close it**: look for a group in step 3's scan whose \`id\` equals the PR's \`group_id\` (for a by-rule/batch PR, match on the concern ids it covered). If **no** current group/concern corresponds, the concern was already fixed on the base (a human fix, or a base change removed the file) — the PR is redundant. Call \`${t("close_pull_request")}\` with a one-line \`comment\` explaining it's resolved on the base. Do not push anything for this PR.
|
|
27
27
|
- **still present → re-derive the fix on the current base**:
|
|
28
28
|
- **read WHY the CI failed, before changing anything** (\`checks_failing\` PRs only): when step 2 gave the PR a \`failing_check_suite_id\`, call \`${t("get_check_suite_logs")}\` with that id FIRST. Re-deriving without reading the failure is exactly how attempt 2 and attempt 3 become identical repeats of attempt 1 — same fix, same red check, three times, until the retry cap escalates a PR nobody learned anything about. Use the returned \`failed_steps\` and log excerpt to decide what to do DIFFERENTLY this attempt. If the log shows the failure is not ours — a broken base, a missing credential, an unrelated flaky job — do not spend an attempt re-deriving: add \`needs-human\` (\`${t("add_labels")}\`), post ONE comment naming the failing step, and move on. Record the cause in the PR body/comment you write below, so the next attempt and any human start from what you found. When the id is absent, or the tool reports no failed runs, say so in the comment and continue with the plain re-derive rather than guessing.
|
|
29
|
-
- **branch**: recreate the remediation branch at the current base HEAD via \`${t("git")}\` (\`git checkout -B
|
|
29
|
+
- **branch**: recreate the remediation branch at the current base HEAD via \`${t("git")}\` (\`git({ command: "checkout", args: ["-B", <the PR's branch>] })\`, the \`branch\` step 2 returned) — \`-B\` force-resets it to the just-scanned base so the diff is only your fix.
|
|
30
30
|
- **fix → validate → plan → keep tests consistent → prove it**: apply the minimal fix for that group exactly as in **Remediate** step 4 (same \`${t("terraform_validate")}\`, \`${t("terraform_plan")}\`, \`${t("terraform_module_tests")}\`, and \`${t("terraform_verify_remediation")}\` gates, and the same guardrails — never open/keep a PR whose validate didn't pass, abandon a group that would destroy a stateful resource, etc.).
|
|
31
31
|
- **force-update the PR branch**: \`${t("push_branch")}\` with \`force: true\` (the PR already exists; force-updating its branch refreshes it in place — do NOT open a second PR). The Terraform-only / secret / destroy guardrails still run at push time.
|
|
32
32
|
- **refresh the body**: \`${t("update_pull_request_body")}\` rebuilt from the fresh \`${t("terraform_verify_remediation")}\` result (the Remediation PR format below), and add a one-line note that it was rebased onto the current base (\`<short-sha>\`) on this run.
|
|
@@ -20,7 +20,7 @@ This mode composes the **Remediate** and **Refactor** verbs into a single run. I
|
|
|
20
20
|
- **commit the fix** (\`git add\` only the changed \`*.tf\`/\`*.tfvars\`, a \`fix(tf): …\` message). Do NOT push yet. This committed state is the refactor's equivalence BASELINE.
|
|
21
21
|
|
|
22
22
|
3. **REFACTOR (phase 2) — modularise on top of the committed fix**: follow the **Refactor** mode's flow:
|
|
23
|
-
- \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` → pick one behaviour-preserving refactor. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
|
|
23
|
+
- \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` → pick one behaviour-preserving refactor; a result marked \`report_only\` (the repository is a module others call) is named in the summary, never proposed. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
|
|
24
24
|
- resolve the module source, wire the \`module\` call against its real interface (\`${t("terraform_module_interface")}\` for a local module dir, \`${t("terraform_module_lookup")}\` for a registry-sourced one), and emit a \`moved {}\` block for EVERY relocated address (\`${t("terraform_generate_moved")}\`).
|
|
25
25
|
- \`terraform fmt\` + \`${t("terraform_validate")}\`, then — **with the refactor edits still UNCOMMITTED** — call \`${t("terraform_equivalence_check")}\`. In this mode it diffs the working tree against the COMMITTED fix (not the run-start commit), so it measures only the refactor. It must return \`equivalent: true\` (zero uncovered moves, resource set + arguments preserved, validate + fmt clean) before you proceed; \`${t("push_branch")}\` hard-blocks an unproven one — and, once this run has added a \`moved {}\` block, it also hard-blocks a push that never ran the check at all, so abandoning the refactor means REVERTING its edits rather than leaving them in with the fix. If it can't be proven equivalent, abandon the refactor and ship the fix alone.
|
|
26
26
|
- **document the module (only when the \`docs\` input is enabled)**: after the equivalence check passes, call \`${t("terraform_module_docs")}\` for the module you created/adopted (pass the relocated addresses as \`moves\`). Do not hand-write docs — the tool builds from the interface, moved blocks, and equivalence verdict.
|
package/src/modes/remediate.ts
CHANGED
|
@@ -34,8 +34,8 @@ export function remediateMode(t: ToolRef): Mode {
|
|
|
34
34
|
|
|
35
35
|
4. **for the chosen group**:
|
|
36
36
|
- **base branch**: this run's base branch is resolved deterministically — \`${t("create_pull_request")}\` targets the \`base_branch\` input if set, else the branch the run started on, else the repository's default branch (\`main\`, or \`master\`). You do not choose it; just **omit** the \`base\` argument when opening the PR (below) and it is filled in.
|
|
37
|
-
- **idempotency**: the remediation branch is \`remediate/<group-id
|
|
38
|
-
- **branch**: create
|
|
37
|
+
- **idempotency**: the remediation branch is the group's \`branch\` (\`remediate/<group-id>\`, with a \`--<base>\` suffix when the PR targets a non-default branch) — use it exactly, never a name of your own. Before doing anything, check whether that branch or an open PR for it already exists (\`${t("git")}\` / \`${t("get_pull_request")}\`). If one exists, update it rather than opening a duplicate.
|
|
38
|
+
- **branch**: create the group's \`branch\` from the **current HEAD** (the checkout that was just scanned) via \`${t("git")}\` (\`git({ command: "checkout", args: ["-b", <branch>] })\`). Do NOT switch to a different base first — branching from the scanned checkout keeps the PR diff to exactly your fix.
|
|
39
39
|
- **honest refusal (decide BEFORE fixing)**: if the group's concerns appear in the scan's \`refusal_candidates\` (the fix needs a human decision — narrowing an IAM wildcard, a KMS key policy, a real ingress CIDR), do **not** guess a fix that could break the stack. Instead open a structured issue (\`${t("create_issue")}\`) describing the concern, why it isn't auto-fixed, and what a human should do, and skip the PR for that group. A proven fix or an honest refusal — never a guessed, unverifiable PR.
|
|
40
40
|
- **propose, then let me steer (when there's no single right fix)**: distinct from honest refusal (which refuses a fix a human must *decide*), this is for a finding with **2–3 genuinely distinct, defensible fixes** that differ in trade-offs, not correctness (e.g. encrypt with an AWS-managed key **vs** a customer-managed KMS key; a narrow security-group rule **vs** a prefix list **vs** a VPC endpoint). When such a fork exists **and the triggering comment did not already select a strategy**, do **not** silently pick for the reviewer: via \`${t("create_issue_comment")}\` post one short comment listing the options as **A / B / C** — each a single line (what it does + its trade-off) — and ask the reviewer to reply \`${COMMENT_COMMAND} fix #<concern-id> with strategy <A|B|C>\`. Then **skip the PR for this group** this run and note it in your final report (it resumes when the reviewer replies). When the comment **did** select one (\`fix #<id> with strategy B\`, or a bare \`strategy B\` reply on the proposal thread), apply **exactly** that strategy — don't second-guess it. Reserve this for real forks in the road; a fix with one obvious correct answer just gets made.
|
|
41
41
|
- **reuse a proven fix (optional, do this BEFORE editing)**: call \`${t("terraform_fix_memory")}\`. When proven-fix memory is enabled it returns any patterns whose \`finding_type\` matches this group's concerns — each a transformation that already passed a \`✗→✓\` proof on this repo or elsewhere in the fleet, with its \`remediation_hint\` + a \`before_example\`. Use a matching pattern as your STARTING POINT (it saves you rediscovering the approach), but ADAPT it to this repo's own resources — it is a prior, never a patch to paste. You MUST still run \`${t("terraform_verify_remediation")}\` to prove \`✗→✓\` here; a pattern is never blind-applied and never substitutes for the per-target proof. Returns an empty list (never an error) when memory is off or nothing matches — then fix from first principles as usual.
|
|
@@ -137,6 +137,7 @@ export const Inputs = type({
|
|
|
137
137
|
// org-repo | author | third-party). Structured scope, not prompt text — see
|
|
138
138
|
// parseRefactorSource / withRefactorSource. Gates the behaviour-altering b.4.
|
|
139
139
|
"refactor_source?": type.string.or("undefined"),
|
|
140
|
+
"refactor_examples?": type.string.or("undefined"),
|
|
140
141
|
"review_instructions?": type.string.or("undefined"),
|
|
141
142
|
"fp_filtering_instructions?": type.string.or("undefined"),
|
|
142
143
|
"remediation_instructions?": type.string.or("undefined"),
|
|
@@ -237,6 +238,7 @@ function resolveNonPromptInputs() {
|
|
|
237
238
|
registry_token: core.getInput("registry_token") || undefined,
|
|
238
239
|
registry_host: core.getInput("registry_host") || undefined,
|
|
239
240
|
refactor_source: core.getInput("refactor_source") || undefined,
|
|
241
|
+
refactor_examples: core.getInput("refactor_examples") || undefined,
|
|
240
242
|
review_instructions: core.getInput("review_instructions") || undefined,
|
|
241
243
|
fp_filtering_instructions: core.getInput("fp_filtering_instructions") || undefined,
|
|
242
244
|
remediation_instructions: core.getInput("remediation_instructions") || undefined,
|
|
@@ -690,6 +692,8 @@ export function resolvePayload(
|
|
|
690
692
|
// Refactor mode for the b.4 consent gate. undefined → auto. Action-input/
|
|
691
693
|
// trusted-dispatch only (not the untrusted repo file).
|
|
692
694
|
refactorSource,
|
|
695
|
+
// opt in to refactoring `examples/` roots, which Refactor leaves alone by default
|
|
696
|
+
refactorExamples: parseBooleanInput(inputs.refactor_examples),
|
|
693
697
|
// a `@infraweaver fix …` command parsed from the triggering comment
|
|
694
698
|
// body (the raw prompt), scoping the run to a specific concern/severity/file.
|
|
695
699
|
// null when the prompt isn't a recognised command.
|