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.
- 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 +846 -412
- package/dist/index.js +806 -372
- 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/awsPrices.d.ts +1 -1
- package/dist/mcp/terraform/azurePrices.d.ts +1 -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/scanSession.d.ts +2 -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 +56 -0
- 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/awsPrices.ts +1 -1
- package/src/mcp/terraform/azurePrices.ts +23 -23
- package/src/mcp/terraform/concernResult.ts +18 -9
- 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/scanSession.ts +6 -0
- 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/tools/verifyRemediation.ts +23 -1
- package/src/mcp/terraform/tree.ts +8 -0
- package/src/mcp/terraform/types.ts +2 -1
- package/src/mcp/terraform/verifySites.ts +124 -0
- 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,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
|
};
|
|
@@ -275,38 +275,79 @@ export function computeScanDelta(input: ScanDeltaInput): ScanDelta {
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
/**
|
|
278
|
-
* Pair
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
278
|
+
* Pair a NEW resource with a vanished one — a resource whose findings resolved
|
|
279
|
+
* because it no longer exists where it was: renamed within its module, or moved
|
|
280
|
+
* under the same name to another directory (a resource extracted into a module
|
|
281
|
+
* without a `moved` block).
|
|
282
|
+
*
|
|
283
|
+
* Resources pair one-to-one, as `moved` blocks do: Terraform rejects two blocks
|
|
284
|
+
* with the same `from` or the same `to`, so a pairing made per finding — one new
|
|
285
|
+
* bucket matched to two old ones because each shared a different rule — would
|
|
286
|
+
* advise blocks that cannot be written. Each candidate pair scores the findings
|
|
287
|
+
* the two resources share (rule by rule, instance by instance), the best pairs
|
|
288
|
+
* are taken first, and ties fall to address order so the result is stable. Only
|
|
289
|
+
* the shared findings of a pair are labelled; a rule the vanished resource never
|
|
290
|
+
* broke is new on the renamed one too.
|
|
283
291
|
*/
|
|
284
292
|
function markPossibleRenames(
|
|
285
293
|
introduced: DeltaFinding[],
|
|
286
294
|
resolved: DeltaFinding[],
|
|
287
295
|
headAddresses: (dir: string) => ReadonlySet<string>,
|
|
288
296
|
): void {
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
297
|
+
const byResource = (findings: DeltaFinding[]) => {
|
|
298
|
+
const out = new Map<string, { dir: string; address: string; findings: DeltaFinding[] }>();
|
|
299
|
+
for (const f of [...findings].sort((a, b) => byLocation(a.concern, b.concern))) {
|
|
300
|
+
if (f.address === null) continue;
|
|
301
|
+
const dir = dirOf(f.concern.location.file);
|
|
302
|
+
const key = `${dir}|${f.address}`;
|
|
303
|
+
const entry = out.get(key);
|
|
304
|
+
if (entry) entry.findings.push(f);
|
|
305
|
+
else out.set(key, { dir, address: f.address, findings: [f] });
|
|
306
|
+
}
|
|
307
|
+
return [...out.values()];
|
|
308
|
+
};
|
|
309
|
+
const vanished = byResource(resolved).filter((r) => !headAddresses(r.dir).has(r.address));
|
|
310
|
+
const appeared = byResource(introduced);
|
|
311
|
+
|
|
312
|
+
// the findings of `to` that match one of `from`'s, each of `from`'s used once
|
|
313
|
+
const shared = (from: (typeof vanished)[number], to: (typeof appeared)[number]) => {
|
|
314
|
+
const left = new Map<string, number>();
|
|
315
|
+
for (const f of from.findings)
|
|
316
|
+
left.set(ruleKey(f.concern), (left.get(ruleKey(f.concern)) ?? 0) + 1);
|
|
317
|
+
return to.findings.filter((f) => {
|
|
318
|
+
const n = left.get(ruleKey(f.concern)) ?? 0;
|
|
319
|
+
if (n === 0) return false;
|
|
320
|
+
left.set(ruleKey(f.concern), n - 1);
|
|
321
|
+
return true;
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
const order = (a: { dir: string; address: string }, b: { dir: string; address: string }) =>
|
|
325
|
+
a.dir.localeCompare(b.dir) || a.address.localeCompare(b.address);
|
|
326
|
+
const candidates = vanished.flatMap((from) =>
|
|
327
|
+
appeared
|
|
328
|
+
.filter(
|
|
329
|
+
(to) =>
|
|
330
|
+
// a `moved` block cannot change a resource's type
|
|
331
|
+
typeOf(from.address) === typeOf(to.address) &&
|
|
332
|
+
// renamed in place, or moved elsewhere under its own name
|
|
333
|
+
(from.dir === to.dir) !== (from.address === to.address),
|
|
334
|
+
)
|
|
335
|
+
.map((to) => ({ from, to, findings: shared(from, to) }))
|
|
336
|
+
.filter((p) => p.findings.length > 0),
|
|
337
|
+
);
|
|
338
|
+
candidates.sort(
|
|
339
|
+
(a, b) => b.findings.length - a.findings.length || order(a.from, b.from) || order(a.to, b.to),
|
|
340
|
+
);
|
|
341
|
+
const used = new Set<object>();
|
|
342
|
+
for (const { from, to, findings } of candidates) {
|
|
343
|
+
if (used.has(from) || used.has(to)) continue;
|
|
344
|
+
used.add(from);
|
|
345
|
+
used.add(to);
|
|
346
|
+
const file = from.findings[0]?.concern.location.file as string;
|
|
347
|
+
for (const finding of findings) {
|
|
348
|
+
finding.possiblyPreexisting = true;
|
|
349
|
+
finding.renamedFrom = { address: from.address, file };
|
|
350
|
+
}
|
|
310
351
|
}
|
|
311
352
|
}
|
|
312
353
|
|
|
@@ -77,6 +77,12 @@ export interface ScanSession {
|
|
|
77
77
|
// agent took from the FIRST scan (`concernKeyOf`), or it falls back to
|
|
78
78
|
// matching ids exactly and calls every line-shifted finding resolved.
|
|
79
79
|
concernsById?: Map<string, Concern>;
|
|
80
|
+
// the resource each reported concern sat in when it was reported (by id), and
|
|
81
|
+
// per (scanner, rule, file) key the resources the rule fired on the first time
|
|
82
|
+
// the run saw that key. terraform_verify_remediation reads both to count a fix
|
|
83
|
+
// on one resource while the rule still fires on another (see verifySites).
|
|
84
|
+
siteById?: Map<string, string | null>;
|
|
85
|
+
firstSitesByKey?: Map<string, Set<string | null>>;
|
|
80
86
|
// the findings gate's concern set: scanned by the engine itself before the
|
|
81
87
|
// agent starts, on the tree the run was invoked on, at the CONFIGURED
|
|
82
88
|
// threshold and scope. read at end-of-run by finalizeSuccessRun for the
|
|
@@ -6,6 +6,7 @@ import { PRODUCT_NAME } from "#app/brand";
|
|
|
6
6
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
7
7
|
import { walkTfFiles } from "#app/mcp/modules";
|
|
8
8
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
9
|
+
import { listOpenRemediationPrs } from "#app/mcp/staleFix";
|
|
9
10
|
import { buildConcernResult } from "#app/mcp/terraform/concernResult";
|
|
10
11
|
import { indexTfResources } from "#app/mcp/terraform/cspm";
|
|
11
12
|
import { docUrlsForGroup, ruleDocUrl } from "#app/mcp/terraform/decisions";
|
|
@@ -180,6 +181,7 @@ export function DetectPlanDriftTool(ctx: LocalToolContext) {
|
|
|
180
181
|
// drift is a narrow concern source — join any prior scan baseline so a
|
|
181
182
|
// later ✗→✓ verify doesn't read the whole workspace as regressions.
|
|
182
183
|
baselineMode: "merge",
|
|
184
|
+
openRemediationPrs: await listOpenRemediationPrs(ctx),
|
|
183
185
|
});
|
|
184
186
|
|
|
185
187
|
log.info(
|
|
@@ -7,6 +7,7 @@ import { PRODUCT_NAME } from "#app/brand";
|
|
|
7
7
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
8
8
|
import { readTextCapped, walkTfFiles } from "#app/mcp/modules";
|
|
9
9
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
10
|
+
import { listOpenRemediationPrs } from "#app/mcp/staleFix";
|
|
10
11
|
import { buildConcernResult } from "#app/mcp/terraform/concernResult";
|
|
11
12
|
import { cappedSummary, SeedCapParams, seedCapsFrom } from "#app/mcp/terraform/coverage";
|
|
12
13
|
import {
|
|
@@ -220,6 +221,7 @@ export function IngestExternalFindingsTool(ctx: LocalToolContext) {
|
|
|
220
221
|
// baseline so a later ✗→✓ verify doesn't read the workspace as regressions.
|
|
221
222
|
baselineMode: "merge",
|
|
222
223
|
seedCaps: seedCapsFrom(input),
|
|
224
|
+
openRemediationPrs: await listOpenRemediationPrs(ctx),
|
|
223
225
|
});
|
|
224
226
|
const cappedBlock = cappedSummary(capped);
|
|
225
227
|
|
|
@@ -2,7 +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,
|
|
5
|
+
import { isExamplePath, libraryScope, reportOnly } from "#app/mcp/terraform/refactorScope";
|
|
6
6
|
import { log } from "#app/utils/cli";
|
|
7
7
|
|
|
8
8
|
// --- the tool ----------------------------------------------------------------
|
|
@@ -29,15 +29,15 @@ export function NormalizationCandidatesTool(ctx: LocalToolContext) {
|
|
|
29
29
|
parameters: NormalizationCandidatesParams,
|
|
30
30
|
execute: execute(async () => {
|
|
31
31
|
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const library = libraryScope(cwd);
|
|
33
|
+
const files = findNormalizationCandidates(cwd)
|
|
34
|
+
.filter((f) => ctx.payload.refactorExamples || !isExamplePath(f.file))
|
|
35
|
+
.map((f) => ({ ...reportOnly(library(f.file)), ...f }));
|
|
35
36
|
const total = files.reduce((n, f) => n + f.count, 0);
|
|
36
37
|
log.info(
|
|
37
38
|
`» terraform_normalization_candidates: ${total} site(s) across ${files.length} file(s)`,
|
|
38
39
|
);
|
|
39
40
|
return toolOk({
|
|
40
|
-
...reportOnly(libraryRepoReason(cwd)),
|
|
41
41
|
file_count: files.length,
|
|
42
42
|
total,
|
|
43
43
|
files,
|
|
@@ -4,6 +4,7 @@ import { type } from "arktype";
|
|
|
4
4
|
import { PRODUCT_NAME } from "#app/brand";
|
|
5
5
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
6
6
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
7
|
+
import { listOpenRemediationPrs } from "#app/mcp/staleFix";
|
|
7
8
|
import { buildConcernResult } from "#app/mcp/terraform/concernResult";
|
|
8
9
|
import { cappedSummary, SeedCapParams, seedCapsFrom } from "#app/mcp/terraform/coverage";
|
|
9
10
|
import { docUrlsForGroup, ruleDocUrl } from "#app/mcp/terraform/decisions";
|
|
@@ -102,6 +103,7 @@ export function ReadFindingsTool(ctx: LocalToolContext) {
|
|
|
102
103
|
grouping,
|
|
103
104
|
autonomyThreshold,
|
|
104
105
|
seedCaps: seedCapsFrom(input),
|
|
106
|
+
openRemediationPrs: await listOpenRemediationPrs(ctx),
|
|
105
107
|
});
|
|
106
108
|
const cappedBlock = cappedSummary(capped);
|
|
107
109
|
|
|
@@ -4,6 +4,7 @@ import { type } from "arktype";
|
|
|
4
4
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
5
5
|
import { walkTfFiles } from "#app/mcp/modules";
|
|
6
6
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
7
|
+
import { listOpenRemediationPrs } from "#app/mcp/staleFix";
|
|
7
8
|
import { loadBaseline, loadResolvedLedger } from "#app/mcp/terraform/baseline";
|
|
8
9
|
import { buildConcernResult } from "#app/mcp/terraform/concernResult";
|
|
9
10
|
import {
|
|
@@ -148,6 +149,8 @@ export async function runScan(
|
|
|
148
149
|
autonomyThreshold,
|
|
149
150
|
inScope,
|
|
150
151
|
priority,
|
|
152
|
+
openRemediationPrs: await listOpenRemediationPrs(ctx),
|
|
153
|
+
linesMatchTree: true,
|
|
151
154
|
},
|
|
152
155
|
);
|
|
153
156
|
const { active, suppressed, groups, batchPlan, bySeverity: by_severity } = result;
|
|
@@ -3,6 +3,7 @@ import type { LocalToolContext } from "#app/mcp/localContext";
|
|
|
3
3
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
4
4
|
import { computeConfidence } from "#app/mcp/terraform/decisions";
|
|
5
5
|
import { captureProvenFix } from "#app/mcp/terraform/fixMemory";
|
|
6
|
+
import { declaredAddresses } from "#app/mcp/terraform/scanDelta";
|
|
6
7
|
import {
|
|
7
8
|
computeRegressions,
|
|
8
9
|
computeRemediationVerdict,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
} from "#app/mcp/terraform/scanners";
|
|
13
14
|
import { hasBaseline, scanSessionOf } from "#app/mcp/terraform/scanSession";
|
|
14
15
|
import { concernKeyOf, dedupe } from "#app/mcp/terraform/types";
|
|
16
|
+
import { fileReader, resolvedAtSite, siteOf } from "#app/mcp/terraform/verifySites";
|
|
15
17
|
import { log } from "#app/utils/cli";
|
|
16
18
|
import { resolveToolSelection } from "#app/utils/prompt/toolSelection";
|
|
17
19
|
import { resolveModuleCredentialEnv } from "#app/utils/setup/registryAuth";
|
|
@@ -82,7 +84,27 @@ export async function runVerifyRemediation(
|
|
|
82
84
|
if (key !== undefined) keyed.push({ id, key });
|
|
83
85
|
else unkeyed.push(id);
|
|
84
86
|
}
|
|
85
|
-
const
|
|
87
|
+
const byKey = partitionByKey(keyed, currentKeys);
|
|
88
|
+
// a rule still firing in the file may have been fixed on THIS finding's
|
|
89
|
+
// resource; count it only under the conditions verifySites spells out
|
|
90
|
+
const read = fileReader(cwd);
|
|
91
|
+
const remainingKeyed = keyed.filter((k) => byKey.remaining.includes(k.id));
|
|
92
|
+
const atSite = new Set(
|
|
93
|
+
resolvedAtSite({
|
|
94
|
+
requested: remainingKeyed.map((k) => ({
|
|
95
|
+
...k,
|
|
96
|
+
site: session.siteById?.get(k.id) ?? null,
|
|
97
|
+
file: reported.get(k.id)?.location.file,
|
|
98
|
+
})),
|
|
99
|
+
current: currentConcerns.map((c) => ({ key: concernKeyOf(c), site: siteOf(c, read) })),
|
|
100
|
+
firstSites: session.firstSitesByKey ?? new Map(),
|
|
101
|
+
declared: (file) => new Set(declaredAddresses(read(file) ?? "")),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const keyedVerdict = {
|
|
105
|
+
resolved: [...byKey.resolved, ...byKey.remaining.filter((id) => atSite.has(id))],
|
|
106
|
+
remaining: byKey.remaining.filter((id) => !atSite.has(id)),
|
|
107
|
+
};
|
|
86
108
|
const fallbackVerdict = computeRemediationVerdict(unkeyed, new Set(currentIds));
|
|
87
109
|
const resolved = [...keyedVerdict.resolved, ...fallbackVerdict.resolved];
|
|
88
110
|
const remaining = [...keyedVerdict.remaining, ...fallbackVerdict.remaining];
|
|
@@ -176,6 +176,14 @@ export function readTofuTree(cwd: string, limits?: TreeReadLimits): SourceTree {
|
|
|
176
176
|
return readTree(cwd, ".tofu", limits);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The `.tfvars` inputs (`.auto.tfvars` included). Not configuration, so no rule
|
|
181
|
+
* reads them as blocks; the hardcoded-secret rule reads their assignments.
|
|
182
|
+
*/
|
|
183
|
+
export function readTfvarsTree(cwd: string, limits?: TreeReadLimits): SourceTree {
|
|
184
|
+
return readTree(cwd, ".tfvars", limits);
|
|
185
|
+
}
|
|
186
|
+
|
|
179
187
|
/** How many oversize paths a gap names before it stops listing them. A gap is a
|
|
180
188
|
* sentence in a report, not a manifest; past a handful the count carries the
|
|
181
189
|
* fact and the list only buries it. */
|
|
@@ -399,7 +399,8 @@ export interface ConcernGroup {
|
|
|
399
399
|
* the file (by-file grouping) or the rule (by-rule grouping). */
|
|
400
400
|
id: string;
|
|
401
401
|
/** the branch its fix is pushed to: `remediate/<id>`, plus `--<base>` when the
|
|
402
|
-
* PR targets a branch other than the repository default
|
|
402
|
+
* PR targets a branch other than the repository default — or the head of the
|
|
403
|
+
* remediation PR already open for this group into the same base. */
|
|
403
404
|
branch?: string;
|
|
404
405
|
/** the group's primary file (by-file) or a human label like "3 files"
|
|
405
406
|
* (by-rule); `files` carries the full list for by-rule groups. */
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which findings a fix resolved where its rule still fires elsewhere in the file.
|
|
3
|
+
*
|
|
4
|
+
* terraform_verify_remediation keys a finding on (scanner, rule, file), so a
|
|
5
|
+
* rule fixed on one resource but still firing on another in the same file read
|
|
6
|
+
* as unfixed for both: AWSGoat's open-egress fix on two of four security groups
|
|
7
|
+
* verified as 0 of 5. Counting by resource instead risks the opposite error —
|
|
8
|
+
* a fix that moves the defect onto a new resource would look resolved — so a
|
|
9
|
+
* finding counts as resolved at its own site only when ALL of these hold:
|
|
10
|
+
*
|
|
11
|
+
* - it was placed on a resource when it was reported, by a scan of the tree
|
|
12
|
+
* as it then stood, and no later scan placed the same id elsewhere;
|
|
13
|
+
* - that resource is still declared in the file (a renamed or deleted one
|
|
14
|
+
* proves nothing);
|
|
15
|
+
* - the rule no longer fires on it;
|
|
16
|
+
* - every place the rule still fires in the file is a resource it already
|
|
17
|
+
* fired on before the fix (no new site, and no finding outside a resource).
|
|
18
|
+
*
|
|
19
|
+
* Anything else keeps the file-level answer, which under-claims and never
|
|
20
|
+
* over-claims.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import type { ScanSession } from "#app/mcp/terraform/scanSession";
|
|
26
|
+
import { resourceAddressAt } from "#app/mcp/terraform/suppressions";
|
|
27
|
+
import { type Concern, concernKeyOf } from "#app/mcp/terraform/types";
|
|
28
|
+
|
|
29
|
+
/** A reader that returns each file under `cwd` once, or null when unreadable. */
|
|
30
|
+
export function fileReader(cwd: string): (file: string) => string | null {
|
|
31
|
+
const cache = new Map<string, string | null>();
|
|
32
|
+
return (file) => {
|
|
33
|
+
if (!cache.has(file)) {
|
|
34
|
+
let text: string | null;
|
|
35
|
+
try {
|
|
36
|
+
text = readFileSync(join(cwd, file), "utf8");
|
|
37
|
+
} catch {
|
|
38
|
+
text = null;
|
|
39
|
+
}
|
|
40
|
+
cache.set(file, text);
|
|
41
|
+
}
|
|
42
|
+
return cache.get(file) ?? null;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The resource a concern sits in, read from the file as it is now. */
|
|
47
|
+
export function siteOf(c: Concern, read: (file: string) => string | null): string | null {
|
|
48
|
+
const text = read(c.location.file);
|
|
49
|
+
return text === null ? null : resourceAddressAt(text, c.location.line);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remember where each reported concern sat, and the first sites per key.
|
|
54
|
+
*
|
|
55
|
+
* `linesMatchTree` says the concerns were scanned off the tree in `cwd` as it
|
|
56
|
+
* is now. Without it (a findings file or external report, which may describe
|
|
57
|
+
* another commit) their lines are not trusted to name today's resources: their
|
|
58
|
+
* ids get no site and their keys no first sites.
|
|
59
|
+
*/
|
|
60
|
+
export function recordConcernSites(
|
|
61
|
+
session: ScanSession,
|
|
62
|
+
concerns: Concern[],
|
|
63
|
+
cwd: string,
|
|
64
|
+
linesMatchTree: boolean,
|
|
65
|
+
): void {
|
|
66
|
+
const read = fileReader(cwd);
|
|
67
|
+
session.siteById ??= new Map();
|
|
68
|
+
session.firstSitesByKey ??= new Map();
|
|
69
|
+
const first = session.firstSitesByKey;
|
|
70
|
+
const fresh = new Map<string, Set<string | null>>();
|
|
71
|
+
for (const c of concerns) {
|
|
72
|
+
const site = linesMatchTree ? siteOf(c, read) : null;
|
|
73
|
+
// an id is line-pinned, so after an edit a re-scan can report a different
|
|
74
|
+
// finding under it. It keeps the site it was first reported at; placed
|
|
75
|
+
// anywhere else later, it is ambiguous and gets none.
|
|
76
|
+
const prior = session.siteById.get(c.id);
|
|
77
|
+
session.siteById.set(c.id, prior === undefined || prior === site ? site : null);
|
|
78
|
+
if (!linesMatchTree) continue;
|
|
79
|
+
const key = concernKeyOf(c);
|
|
80
|
+
if (first.has(key)) continue;
|
|
81
|
+
const sites = fresh.get(key) ?? new Set();
|
|
82
|
+
sites.add(site);
|
|
83
|
+
fresh.set(key, sites);
|
|
84
|
+
}
|
|
85
|
+
for (const [key, sites] of fresh) first.set(key, sites);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SiteRequest {
|
|
89
|
+
id: string;
|
|
90
|
+
/** the (scanner, rule, file) key */
|
|
91
|
+
key: string;
|
|
92
|
+
/** the resource address the finding sat in when reported, or null */
|
|
93
|
+
site: string | null;
|
|
94
|
+
file: string | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolvedAtSite(params: {
|
|
98
|
+
requested: readonly SiteRequest[];
|
|
99
|
+
/** every finding the re-scan reports, with the resource it sits in */
|
|
100
|
+
current: readonly { key: string; site: string | null }[];
|
|
101
|
+
/** per key, the resources the rule fired on in the first scan of the run */
|
|
102
|
+
firstSites: ReadonlyMap<string, ReadonlySet<string | null>>;
|
|
103
|
+
/** the resource addresses a file declares now */
|
|
104
|
+
declared: (file: string) => ReadonlySet<string>;
|
|
105
|
+
}): string[] {
|
|
106
|
+
const firingByKey = new Map<string, (string | null)[]>();
|
|
107
|
+
for (const c of params.current) {
|
|
108
|
+
const list = firingByKey.get(c.key) ?? [];
|
|
109
|
+
list.push(c.site);
|
|
110
|
+
firingByKey.set(c.key, list);
|
|
111
|
+
}
|
|
112
|
+
const out: string[] = [];
|
|
113
|
+
for (const r of params.requested) {
|
|
114
|
+
if (r.site === null || r.file === undefined) continue;
|
|
115
|
+
const firing = firingByKey.get(r.key) ?? [];
|
|
116
|
+
const known = params.firstSites.get(r.key);
|
|
117
|
+
if (known === undefined) continue;
|
|
118
|
+
if (firing.some((s) => s === null || !known.has(s))) continue;
|
|
119
|
+
if (firing.includes(r.site)) continue;
|
|
120
|
+
if (!params.declared(r.file).has(r.site)) continue;
|
|
121
|
+
out.push(r.id);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
package/src/modes/refactor.ts
CHANGED
|
@@ -18,7 +18,7 @@ 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
|
|
21
|
+
**A candidate marked \`report_only\` is an observation, not work.** It sits in the code of a module other configurations call (\`report_only_reason\` says why), and restructuring that 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
22
|
|
|
23
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.
|
|
24
24
|
|
|
@@ -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; a
|
|
23
|
+
- \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` → pick one behaviour-preserving refactor; a candidate marked \`report_only\` (it sits in the code of 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,7 +34,7 @@ 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 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.
|
|
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, or the branch of the PR already open for this group) — 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
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.
|
|
@@ -105,10 +105,11 @@ export async function runAgentWithWatchdogs(params: RunAgentParams): Promise<Age
|
|
|
105
105
|
const costQuota = reserved ? quotas.cost : createCostQuota(quotas.costLimit.full);
|
|
106
106
|
log.info(
|
|
107
107
|
quotas.tokenLimit.full > 0
|
|
108
|
-
? `» run token quota: ${quotas.tokenLimit.full.toLocaleString("en-GB")}
|
|
108
|
+
? `» run token quota: ${quotas.tokenLimit.full.toLocaleString("en-GB")} tokens` +
|
|
109
109
|
(reserved
|
|
110
110
|
? ` (${tokenQuota.limit.toLocaleString("en-GB")} for the run, the rest reserved to deliver findings if it runs out)`
|
|
111
|
-
: "")
|
|
111
|
+
: "") +
|
|
112
|
+
"; a token read from the prompt cache counts as a tenth"
|
|
112
113
|
: `» run token quota: disabled (${RUN_TOKEN_QUOTA_INPUT}: 0)`,
|
|
113
114
|
);
|
|
114
115
|
log.info(
|