infraweaver 0.3.7 → 0.3.9
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 +2 -2
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claudePretoolGate.d.ts +0 -36
- package/dist/agents/toolPlaneWatch.d.ts +31 -0
- package/dist/cli.mjs +31908 -18736
- package/dist/external.d.ts +2 -1
- package/dist/index.js +31992 -18749
- package/dist/internal.js +133 -52
- package/dist/mcp/baseBranch.d.ts +41 -0
- package/dist/mcp/changeSummary.d.ts +1 -1
- package/dist/mcp/findingAudit.d.ts +2 -0
- package/dist/mcp/guardrails.d.ts +38 -0
- package/dist/mcp/localContext.d.ts +1 -1
- package/dist/mcp/moduleExtraction.d.ts +5 -0
- package/dist/mcp/pr.d.ts +0 -31
- package/dist/mcp/reproveSuggestions.d.ts +27 -0
- package/dist/mcp/review.d.ts +126 -2
- package/dist/mcp/reviewCommentLimit.d.ts +17 -0
- package/dist/mcp/reviewComments.d.ts +9 -5
- package/dist/mcp/reviewDedup.d.ts +1 -0
- package/dist/mcp/reviewMarkers.d.ts +10 -0
- package/dist/mcp/reviewProvenance.d.ts +20 -6
- package/dist/mcp/shared.d.ts +15 -0
- package/dist/mcp/suggestionProver.d.ts +3 -2
- package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
- package/dist/mcp/terraform/consolidation.d.ts +26 -0
- package/dist/mcp/terraform/cost.d.ts +2 -2
- package/dist/mcp/terraform/currency.d.ts +11 -0
- package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
- package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
- package/dist/mcp/terraform/evidence.d.ts +10 -0
- package/dist/mcp/terraform/gateScan.d.ts +2 -16
- package/dist/mcp/terraform/hcl.d.ts +15 -0
- package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
- package/dist/mcp/terraform/localModules.d.ts +29 -0
- package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
- package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
- package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
- package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
- package/dist/mcp/terraform/refactor/references.d.ts +15 -0
- package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
- package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
- package/dist/mcp/terraform/scanDelta.d.ts +28 -0
- package/dist/mcp/terraform/scanSession.d.ts +13 -0
- package/dist/mcp/terraform/scanners.d.ts +67 -18
- package/dist/mcp/terraform/secretScan.d.ts +19 -0
- package/dist/mcp/terraform/standardsReport.d.ts +1 -1
- package/dist/mcp/terraform/tools/plan.d.ts +1 -1
- package/dist/mcp/terraform/tools/scan.d.ts +7 -7
- package/dist/mcp/terraform/tools/validate.d.ts +7 -2
- package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
- package/dist/mcp/terraform/treeLayout.d.ts +57 -0
- package/dist/mcp/terraform/types.d.ts +1 -1
- package/dist/phases/applyPlan.d.ts +0 -2
- package/dist/toolState.d.ts +3 -0
- package/dist/utils/agent/codexOAuth.d.ts +1 -1
- package/dist/utils/cli.d.ts +1 -7
- package/dist/utils/cloud/cloudReport.d.ts +1 -1
- package/dist/utils/config/baseRefConfig.d.ts +1 -1
- package/dist/utils/config/infraweaverConfig.d.ts +1 -1
- package/dist/utils/config/payload.d.ts +5 -0
- package/dist/utils/markdownTable.d.ts +6 -0
- package/dist/utils/setup/toolLicensing.d.ts +1 -1
- package/dist/utils/unifiedDiff.d.ts +69 -0
- package/package.json +34 -28
- package/src/agents/claude.ts +48 -13
- package/src/agents/claudePretoolGate.ts +9 -17
- package/src/agents/sessionLabeler.ts +3 -3
- package/src/agents/toolPlaneWatch.ts +71 -0
- package/src/external.ts +3 -1
- package/src/mcp/assess.ts +8 -8
- package/src/mcp/baseBranch.ts +96 -0
- package/src/mcp/changeSummary.ts +10 -11
- package/src/mcp/checkSuite.ts +7 -10
- package/src/mcp/checkout.ts +13 -40
- package/src/mcp/crosswalk.ts +5 -0
- package/src/mcp/findingAudit.ts +1 -1
- package/src/mcp/git.ts +44 -2
- package/src/mcp/guardrails.ts +188 -36
- package/src/mcp/localContext.ts +2 -0
- package/src/mcp/localServer.ts +1 -0
- package/src/mcp/moduleExtraction.ts +17 -5
- package/src/mcp/moduleTests.ts +9 -2
- package/src/mcp/modules.ts +10 -7
- package/src/mcp/pr.ts +17 -65
- package/src/mcp/reproveSuggestions.ts +86 -0
- package/src/mcp/review.ts +249 -75
- package/src/mcp/reviewAnchor.ts +11 -23
- package/src/mcp/reviewCommentLimit.ts +79 -0
- package/src/mcp/reviewComments.ts +34 -81
- package/src/mcp/reviewDedup.ts +1 -1
- package/src/mcp/reviewMarkers.ts +25 -2
- package/src/mcp/reviewProvenance.ts +56 -21
- package/src/mcp/roots.ts +3 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/shared.ts +40 -1
- package/src/mcp/shell.ts +27 -28
- package/src/mcp/suggestionProver.ts +8 -4
- package/src/mcp/terraform/checkovSeverities.ts +1156 -0
- package/src/mcp/terraform/concernResult.ts +2 -1
- package/src/mcp/terraform/consolidation.ts +232 -19
- package/src/mcp/terraform/cost.ts +16 -7
- package/src/mcp/terraform/cspm.ts +8 -4
- package/src/mcp/terraform/currency.ts +28 -12
- package/src/mcp/terraform/decisions.ts +1 -1
- package/src/mcp/terraform/deltaSummary.ts +50 -1
- package/src/mcp/terraform/emittedArtefacts.ts +63 -0
- package/src/mcp/terraform/evidence.ts +23 -3
- package/src/mcp/terraform/gateScan.ts +191 -128
- package/src/mcp/terraform/hcl.ts +27 -2
- package/src/mcp/terraform/idleFloor.ts +2 -1
- package/src/mcp/terraform/initArtefacts.ts +76 -0
- package/src/mcp/terraform/localModules.ts +112 -0
- package/src/mcp/terraform/moduleDocs.ts +2 -5
- package/src/mcp/terraform/moduleFindings.ts +102 -0
- package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
- package/src/mcp/terraform/planPairs.ts +2 -20
- package/src/mcp/terraform/policyGate.ts +0 -15
- package/src/mcp/terraform/refactor/attribution.ts +39 -5
- package/src/mcp/terraform/refactor/equivalence.ts +49 -10
- package/src/mcp/terraform/refactor/expressions.ts +323 -0
- package/src/mcp/terraform/refactor/references.ts +101 -0
- package/src/mcp/terraform/refactor/resources.ts +79 -7
- package/src/mcp/terraform/refactor/substitution.ts +852 -256
- package/src/mcp/terraform/scanDelta.ts +41 -0
- package/src/mcp/terraform/scanSession.ts +30 -0
- package/src/mcp/terraform/scannerCache.ts +4 -0
- package/src/mcp/terraform/scanners.ts +553 -233
- package/src/mcp/terraform/secretScan.ts +131 -0
- package/src/mcp/terraform/standardsReport.ts +14 -13
- package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
- package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
- package/src/mcp/terraform/tools/emitOscal.ts +3 -2
- package/src/mcp/terraform/tools/emitVex.ts +10 -4
- package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
- package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
- package/src/mcp/terraform/tools/plan.ts +30 -17
- package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
- package/src/mcp/terraform/tools/scan.ts +5 -3
- package/src/mcp/terraform/tools/standardsReport.ts +1 -1
- package/src/mcp/terraform/tools/validate.ts +20 -5
- package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
- package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
- package/src/mcp/terraform/treeLayout.ts +378 -0
- package/src/mcp/terraform/types.ts +4 -1
- package/src/mcp/terraform/versionRequirements.ts +11 -2
- package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
- package/src/modes/modernize-deprecated.ts +2 -2
- package/src/modes/refactor.ts +9 -9
- package/src/modes/remediate.ts +1 -1
- package/src/modes/terraform-code-review.ts +7 -3
- package/src/modes/update-dependencies.ts +6 -6
- package/src/phases/applyPlan.ts +33 -14
- package/src/phases/applySuggestions.ts +2 -2
- package/src/phases/runAgentWithWatchdogs.ts +2 -1
- package/src/toolState.ts +7 -0
- package/src/utils/agent/codexOAuth.ts +7 -7
- package/src/utils/agent/openCodeModels.ts +4 -6
- package/src/utils/cli.ts +1 -31
- package/src/utils/cloud/runContext.ts +1 -8
- package/src/utils/config/baseRefConfig.ts +6 -0
- package/src/utils/config/infraweaverConfig.ts +2 -0
- package/src/utils/config/payload.ts +41 -0
- package/src/utils/github/assets.ts +1 -6
- package/src/utils/github/github.ts +42 -150
- package/src/utils/github/token.ts +6 -11
- package/src/utils/log.ts +4 -5
- package/src/utils/markdownTable.ts +11 -0
- package/src/utils/prompt/changeImpact.ts +18 -26
- package/src/utils/prompt/instructions.ts +1 -1
- package/src/utils/prompt/promptDirectives.ts +1 -1
- package/src/utils/prompt/toolSelection.ts +11 -2
- package/src/utils/setup/toolLicensing.ts +7 -0
- package/src/utils/telemetry.ts +2 -1
- package/src/utils/unifiedDiff.ts +120 -0
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { packControlsForConcern, requiredControlViolations } from "#app/mcp/crosswalk";
|
|
10
10
|
import { evaluateGate } from "#app/mcp/terraform/baseline";
|
|
11
|
-
import
|
|
11
|
+
import { count } from "#app/mcp/terraform/prose";
|
|
12
|
+
import {
|
|
13
|
+
type DeltaFinding,
|
|
14
|
+
type GateDelta,
|
|
15
|
+
type UndeclaredRename,
|
|
16
|
+
undeclaredRenames,
|
|
17
|
+
} from "#app/mcp/terraform/scanDelta";
|
|
12
18
|
import { SEVERITY_RANK } from "#app/mcp/terraform/types";
|
|
13
19
|
import { neutralizeMarkup } from "#app/mcp/untrustedContent";
|
|
14
20
|
|
|
@@ -50,6 +56,47 @@ export function deltaVerdict(delta: GateDelta, requiredControls: string[]): Delt
|
|
|
50
56
|
};
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
/** Whether one introduced finding fails the gate on its own — the same two
|
|
60
|
+
* halves `deltaVerdict` applies to the whole delta. */
|
|
61
|
+
export function failsGate(finding: DeltaFinding, requiredControls: string[]): boolean {
|
|
62
|
+
return (
|
|
63
|
+
!evaluateGate([finding.concern], null).pass ||
|
|
64
|
+
requiredControlViolations([finding.concern], requiredControls).length > 0
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Renames a paragraph names before summarising the rest as a count. */
|
|
69
|
+
const MAX_RENAMES_NAMED = 5;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The undeclared renames in a delta as one paragraph — what happened, what it
|
|
73
|
+
* costs at apply, and the one thing to do about it — or "" when there are none.
|
|
74
|
+
* `fmt` code-spans a value the way the caller's surface does: every address and
|
|
75
|
+
* directory came out of the PR's own files.
|
|
76
|
+
*/
|
|
77
|
+
export function renderUndeclaredRenames(
|
|
78
|
+
renames: readonly UndeclaredRename[],
|
|
79
|
+
fmt: (text: string) => string,
|
|
80
|
+
): string {
|
|
81
|
+
if (renames.length === 0) return "";
|
|
82
|
+
const one = renames.length === 1;
|
|
83
|
+
const named = renames.slice(0, MAX_RENAMES_NAMED).map((r) => {
|
|
84
|
+
const what =
|
|
85
|
+
r.from.address === r.to.address
|
|
86
|
+
? `${fmt(r.to.address)} moved from ${fmt(r.from.dir || ".")} to ${fmt(r.to.dir || ".")}`
|
|
87
|
+
: `${fmt(r.from.address)} → ${fmt(r.to.address)}`;
|
|
88
|
+
return `${what} (${count(r.findings, "finding")})`;
|
|
89
|
+
});
|
|
90
|
+
const rest = renames.length - named.length;
|
|
91
|
+
return (
|
|
92
|
+
`**${count(renames.length, "resource")} ${one ? "looks" : "look"} renamed without a \`moved\` block:** ` +
|
|
93
|
+
`${named.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}. ` +
|
|
94
|
+
`Without ${one ? "one" : "them"}, \`terraform apply\` destroys the old resource and creates the new one, ` +
|
|
95
|
+
`and every finding on it counts as new. Add a \`moved { from = … to = … }\` block for ${one ? "it" : "each"} ` +
|
|
96
|
+
"and those findings match their base instead."
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
53
100
|
/** The compliance controls the introduced findings touch, sorted and unique. */
|
|
54
101
|
export function controlsTouched(findings: readonly DeltaFinding[]): string[] {
|
|
55
102
|
return [...new Set(findings.flatMap((f) => packControlsForConcern(f.concern)))].sort();
|
|
@@ -88,6 +135,8 @@ export function renderDeltaSummary(input: {
|
|
|
88
135
|
].filter(Boolean);
|
|
89
136
|
lines.push("", `The gate fails on ${why.join(" and ")}.`);
|
|
90
137
|
}
|
|
138
|
+
const renames = renderUndeclaredRenames(undeclaredRenames(delta.introduced), cell);
|
|
139
|
+
if (renames) lines.push("", renames);
|
|
91
140
|
const controls = controlsTouched(delta.introduced);
|
|
92
141
|
if (controls.length > 0) lines.push("", `Controls touched: ${controls.map(cell).join(", ")}`);
|
|
93
142
|
if (delta.introduced.length > 0) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compliance artefacts this run's emit tools wrote, and whether a push
|
|
3
|
+
* carries them exactly as written.
|
|
4
|
+
*
|
|
5
|
+
* `terraform_emit_evidence` (and the OSCAL and VEX emitters) write into the
|
|
6
|
+
* workspace — `compliance/…` by default — because the artefact is meant to be
|
|
7
|
+
* committed beside the change it describes: a Refactor PR names its evidence
|
|
8
|
+
* bundle so a reviewer can run `verify-evidence` on it. A guarded mode's push is
|
|
9
|
+
* Terraform-only, which refused that file and sent the agent into untracking it.
|
|
10
|
+
*
|
|
11
|
+
* Opening `compliance/**` would let the agent commit anything there. What is
|
|
12
|
+
* admitted instead is narrower: a path one of these tools wrote this run, with
|
|
13
|
+
* the bytes it wrote. An edited artefact, or a file that only looks like one,
|
|
14
|
+
* is still refused.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { writeFileSync } from "node:fs";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { scanSessionOf } from "#app/mcp/terraform/scanSession";
|
|
20
|
+
|
|
21
|
+
/** the directory every emit tool writes to by default, repo-relative. */
|
|
22
|
+
const ARTEFACT_DIR = "compliance/";
|
|
23
|
+
|
|
24
|
+
/** The id git gives a blob with these contents — what `git rev-parse HEAD:<path>` prints. */
|
|
25
|
+
export function gitBlobId(bytes: string): string {
|
|
26
|
+
const content = Buffer.from(bytes, "utf8");
|
|
27
|
+
return createHash("sha1").update(`blob ${content.length}\0`).update(content).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Write an emit tool's artefact to `path` (absolute) and remember the bytes written. */
|
|
31
|
+
export function writeEmittedArtefact(
|
|
32
|
+
toolState: Parameters<typeof scanSessionOf>[0],
|
|
33
|
+
path: string,
|
|
34
|
+
bytes: string,
|
|
35
|
+
): void {
|
|
36
|
+
writeFileSync(path, bytes);
|
|
37
|
+
const session = scanSessionOf(toolState);
|
|
38
|
+
session.emittedArtefacts ??= {};
|
|
39
|
+
const written = session.emittedArtefacts;
|
|
40
|
+
const key = resolve(path);
|
|
41
|
+
// every version: an emit re-run after the first was committed leaves the
|
|
42
|
+
// commit holding the earlier bytes, which the tool wrote just the same
|
|
43
|
+
written[key] = [...new Set([...(written[key] ?? []), gitBlobId(bytes)])];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* True when `file` — repo-relative, as `git diff` names it — is an artefact an
|
|
48
|
+
* emit tool wrote this run and `committedBlob` (its blob id at the pushed
|
|
49
|
+
* commit, null when absent) is one of the versions it wrote.
|
|
50
|
+
*/
|
|
51
|
+
export function isUnchangedEmittedArtefact(
|
|
52
|
+
toolState: Parameters<typeof scanSessionOf>[0],
|
|
53
|
+
repoTop: string,
|
|
54
|
+
file: string,
|
|
55
|
+
committedBlob: string | null,
|
|
56
|
+
): boolean {
|
|
57
|
+
// only where the emit tools write by default: an `output_path` aimed at
|
|
58
|
+
// `.github/CODEOWNERS` or `package.json` would otherwise replace that file
|
|
59
|
+
// with an artefact and pass as "committed as emitted"
|
|
60
|
+
if (!file.startsWith(ARTEFACT_DIR)) return false;
|
|
61
|
+
const written = scanSessionOf(toolState).emittedArtefacts?.[resolve(repoTop, file)];
|
|
62
|
+
return committedBlob !== null && (written?.includes(committedBlob) ?? false);
|
|
63
|
+
}
|
|
@@ -311,6 +311,23 @@ export interface RefactorEquivalenceInput {
|
|
|
311
311
|
values_verified?: number;
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
/**
|
|
315
|
+
* What a proposed-unproven change IS, named from the mode that made it. The
|
|
316
|
+
* agent's flag says only that the change is unproven; a signed statement that
|
|
317
|
+
* called a version bump a module adoption would misdescribe the artefact it
|
|
318
|
+
* signs.
|
|
319
|
+
*/
|
|
320
|
+
export function proposedChangeOf(mode: string | undefined): string {
|
|
321
|
+
switch (mode) {
|
|
322
|
+
case "UpdateDependencies":
|
|
323
|
+
return "dependency version change";
|
|
324
|
+
case "ModernizeDeprecated":
|
|
325
|
+
return "deprecated-pattern migration";
|
|
326
|
+
default:
|
|
327
|
+
return "third-party-module adoption";
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
314
331
|
/**
|
|
315
332
|
* Build the refactor evidence statement (pure). Returns undefined when nothing
|
|
316
333
|
* refactor-related happened (no equivalence proof, not a proposed-unproven b.4,
|
|
@@ -330,8 +347,12 @@ export function buildRefactorStatement(args: {
|
|
|
330
347
|
| undefined;
|
|
331
348
|
/** the agent recorded the reviewed-plan outcome (b.4 with credentials). */
|
|
332
349
|
reviewedPlan?: boolean;
|
|
350
|
+
/** what the unproven change is (`proposedChangeOf`); a module adoption when
|
|
351
|
+
* absent. */
|
|
352
|
+
change?: string;
|
|
333
353
|
}): EvidenceRefactorStatement | undefined {
|
|
334
354
|
const { equivalence, provenance, proposedUnproven, planTotals, reviewedPlan } = args;
|
|
355
|
+
const change = args.change ?? proposedChangeOf(undefined);
|
|
335
356
|
if (!equivalence && !proposedUnproven && !reviewedPlan && provenance.length === 0)
|
|
336
357
|
return undefined;
|
|
337
358
|
|
|
@@ -371,14 +392,13 @@ export function buildRefactorStatement(args: {
|
|
|
371
392
|
if (planTotals) {
|
|
372
393
|
asserts =
|
|
373
394
|
"nothing about runtime behaviour beyond what the recorded terraform plan shows — this " +
|
|
374
|
-
|
|
395
|
+
`${change} is behaviour-ALTERING and ships for human review of that plan.`;
|
|
375
396
|
doesNotAssert =
|
|
376
397
|
"behaviour-equivalence (it alters behaviour and is not claimed equivalent). A terraform " +
|
|
377
398
|
`plan WAS recorded (+${planTotals.to_add} ~${planTotals.to_change} -${planTotals.to_destroy}); ` +
|
|
378
399
|
"the claim rests on a human reviewing that delta, which this bundle cannot assert.";
|
|
379
400
|
} else {
|
|
380
|
-
asserts =
|
|
381
|
-
"nothing about runtime behaviour — this third-party-module adoption is PROPOSED only and was not verified against a plan.";
|
|
401
|
+
asserts = `nothing about runtime behaviour — this ${change} is PROPOSED only and was not verified against a plan.`;
|
|
382
402
|
doesNotAssert =
|
|
383
403
|
"behaviour-equivalence (it alters behaviour and is not claimed equivalent); no terraform plan was run.";
|
|
384
404
|
}
|
|
@@ -4,6 +4,7 @@ import type { LocalToolContext } from "#app/mcp/localContext";
|
|
|
4
4
|
import { walkTfFiles } from "#app/mcp/modules";
|
|
5
5
|
import { loadBaselineForRun, loadResolvedLedger } from "#app/mcp/terraform/baseline";
|
|
6
6
|
import { annotateConcerns } from "#app/mcp/terraform/concernResult";
|
|
7
|
+
import { initArtefacts, removeNewInitArtefacts } from "#app/mcp/terraform/initArtefacts";
|
|
7
8
|
import { lineageResolver, repoFileReader } from "#app/mcp/terraform/lineage";
|
|
8
9
|
import {
|
|
9
10
|
computeScanDelta,
|
|
@@ -14,10 +15,12 @@ import {
|
|
|
14
15
|
parseMovedBlocks,
|
|
15
16
|
} from "#app/mcp/terraform/scanDelta";
|
|
16
17
|
import { runScannersCached } from "#app/mcp/terraform/scannerCache";
|
|
17
|
-
import { changedTerraformFiles
|
|
18
|
+
import { changedTerraformFiles } from "#app/mcp/terraform/scanners";
|
|
18
19
|
import { scanSessionOf } from "#app/mcp/terraform/scanSession";
|
|
20
|
+
import { scanAddedSecrets } from "#app/mcp/terraform/secretScan";
|
|
19
21
|
import { run } from "#app/mcp/terraform/subprocess";
|
|
20
22
|
import { inChangedFiles, type ScanScanners } from "#app/mcp/terraform/tools/scan";
|
|
23
|
+
import { scopeFor, treeLayout } from "#app/mcp/terraform/treeLayout";
|
|
21
24
|
import {
|
|
22
25
|
type Concern,
|
|
23
26
|
isReviewedTerraformFile,
|
|
@@ -53,11 +56,10 @@ const realScanners: ScanScanners = { runScannersCached, changedTerraformFiles };
|
|
|
53
56
|
export async function runGateScan(
|
|
54
57
|
ctx: LocalToolContext,
|
|
55
58
|
scanners: ScanScanners = realScanners,
|
|
56
|
-
isTerraform?: (c: Concern) => boolean,
|
|
57
59
|
): Promise<Concern[] | undefined> {
|
|
58
60
|
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
59
61
|
if (walkTfFiles(cwd).length === 0) return undefined;
|
|
60
|
-
return scanTree(ctx, cwd, diffScope(ctx, cwd, scanners), scanners
|
|
62
|
+
return scanTree(ctx, cwd, diffScope(ctx, cwd, scanners), scanners);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/** What a pull-request review judges: its own file scope, JSON syntax included. */
|
|
@@ -68,13 +70,17 @@ function diffScope(ctx: LocalToolContext, cwd: string, scanners: ScanScanners):
|
|
|
68
70
|
return (ctx.payload.scanScope ?? "full") === "diff" ? scanners.changedTerraformFiles(cwd) : null;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* One gate-configured scan of the tree at `cwd`, kept to `changed` when set.
|
|
75
|
+
* `scope` limits it to those directories (`scopeFor`).
|
|
76
|
+
*/
|
|
72
77
|
async function scanTree(
|
|
73
78
|
ctx: LocalToolContext,
|
|
74
79
|
cwd: string,
|
|
75
80
|
changed: Set<string> | null,
|
|
76
81
|
scanners: ScanScanners,
|
|
77
82
|
isTerraform?: (c: Concern) => boolean,
|
|
83
|
+
scope?: string[],
|
|
78
84
|
): Promise<Concern[]> {
|
|
79
85
|
const outcomes = await scanners.runScannersCached(cwd, {
|
|
80
86
|
selection: resolveToolSelection(ctx.payload),
|
|
@@ -82,6 +88,8 @@ async function scanTree(
|
|
|
82
88
|
downloadExternalModules: undefined,
|
|
83
89
|
toolsNetwork: ctx.payload.toolsNetwork,
|
|
84
90
|
repeatedLiteralThreshold: ctx.payload.repeatedLiteralThreshold,
|
|
91
|
+
varFiles: ctx.payload.varFiles,
|
|
92
|
+
...(scope ? { scope } : {}),
|
|
85
93
|
});
|
|
86
94
|
|
|
87
95
|
const threshold = (ctx.payload.severityThreshold as Severity | undefined) ?? "low";
|
|
@@ -91,25 +99,27 @@ async function scanTree(
|
|
|
91
99
|
);
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
/** The PR's base, checked out beside the head. */
|
|
103
|
+
interface BaseCheckout {
|
|
104
|
+
mergeBase: string;
|
|
105
|
+
cwd: string;
|
|
106
|
+
close: () => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
94
109
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* Undefined when the base commit is not in the clone (a shallow checkout) or the
|
|
104
|
-
* worktree cannot be made. The run then gates as it always has, and says why.
|
|
110
|
+
* Check out the base of a pull request in a detached worktree, so the checkout
|
|
111
|
+
* the agent works in is never touched. The base is the merge base of the PR's
|
|
112
|
+
* base commit and HEAD, which is the base commit itself on GitHub's
|
|
113
|
+
* `refs/pull/N/merge` checkout and the fork point on a head checkout — "what
|
|
114
|
+
* this PR changed" either way. Undefined when the base commit is not in the
|
|
115
|
+
* clone (a shallow checkout) or the worktree cannot be made: the run then
|
|
116
|
+
* gates as it always has, and says why.
|
|
105
117
|
*/
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
pr: Pick<PullRequestRunContext, "baseSha"
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
): Promise<GateDelta | undefined> {
|
|
112
|
-
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
118
|
+
function checkOutBase(
|
|
119
|
+
cwd: string,
|
|
120
|
+
pr: Pick<PullRequestRunContext, "baseSha">,
|
|
121
|
+
tmp: string,
|
|
122
|
+
): BaseCheckout | undefined {
|
|
113
123
|
if (run("git", ["cat-file", "-e", `${pr.baseSha}^{commit}`], cwd).status !== 0) {
|
|
114
124
|
log.warning(
|
|
115
125
|
`» scan delta: base commit ${pr.baseSha.slice(0, 7)} is not in this clone — ` +
|
|
@@ -119,59 +129,169 @@ export async function runScanDelta(
|
|
|
119
129
|
}
|
|
120
130
|
const mergeBase = run("git", ["merge-base", pr.baseSha, "HEAD"], cwd).stdout.trim() || pr.baseSha;
|
|
121
131
|
const prefix = run("git", ["rev-parse", "--show-prefix"], cwd).stdout.trim();
|
|
122
|
-
|
|
123
132
|
// a fresh parent from mkdtemp, the worktree in a not-yet-existing child: git
|
|
124
133
|
// refuses to add a worktree at a path that exists, and a predictable name is
|
|
125
134
|
// one a local actor could pre-create.
|
|
126
|
-
const parent = mkdtempSync(join(
|
|
135
|
+
const parent = mkdtempSync(join(tmp, "scan-delta-base-"));
|
|
127
136
|
const worktree = join(parent, "wt");
|
|
128
137
|
if (run("git", ["worktree", "add", "--detach", worktree, mergeBase], cwd).status !== 0) {
|
|
129
138
|
rmSync(parent, { recursive: true, force: true });
|
|
130
139
|
log.warning("» scan delta: could not check out the base — every finding is treated as new.");
|
|
131
140
|
return undefined;
|
|
132
141
|
}
|
|
133
|
-
|
|
142
|
+
return {
|
|
143
|
+
mergeBase,
|
|
144
|
+
cwd: prefix ? join(worktree, prefix) : worktree,
|
|
145
|
+
close: () => {
|
|
146
|
+
run("git", ["worktree", "remove", "--force", worktree], cwd);
|
|
147
|
+
rmSync(parent, { recursive: true, force: true });
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Every file the PR changed, relative to `cwd`; null when git cannot say. */
|
|
153
|
+
function changedSince(cwd: string, mergeBase: string): string[] | null {
|
|
154
|
+
const diff = run("git", ["diff", "--name-only", "--no-renames", mergeBase, "HEAD"], cwd);
|
|
155
|
+
if (diff.status !== 0) return null;
|
|
156
|
+
const prefix = run("git", ["rev-parse", "--show-prefix"], cwd).stdout.trim();
|
|
157
|
+
return diff.stdout
|
|
158
|
+
.split("\n")
|
|
159
|
+
.map((f) => f.trim())
|
|
160
|
+
.filter((f) => f !== "" && f.startsWith(prefix))
|
|
161
|
+
.map((f) => f.slice(prefix.length));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The directories both scans of a review cover: the root modules the PR's
|
|
166
|
+
* changes reach, on either side, with their local modules. A root the PR cannot
|
|
167
|
+
* reach has the same findings on both sides, so scanning it changes no count
|
|
168
|
+
* the review reports or the gate judges — only how long it takes. Undefined
|
|
169
|
+
* (the whole tree) when the change cannot be attributed.
|
|
170
|
+
*/
|
|
171
|
+
function reviewScope(ctx: LocalToolContext, cwd: string, base: BaseCheckout): string[] | undefined {
|
|
172
|
+
const changed = changedSince(cwd, base.mergeBase);
|
|
173
|
+
if (!changed) return undefined;
|
|
174
|
+
const head = treeLayout(cwd, ctx.payload.varFiles);
|
|
175
|
+
const scope = scopeFor([head, treeLayout(base.cwd, ctx.payload.varFiles)], changed);
|
|
176
|
+
if (!scope) {
|
|
177
|
+
log.info("» scan delta: a scanner setting changed — scanning every root module");
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const reached = head.roots.filter((r) => scope.includes(r.dir)).length;
|
|
181
|
+
log.info(
|
|
182
|
+
`» scan delta: scanning the ${reached} of ${head.roots.length} root module(s) this change reaches`,
|
|
183
|
+
);
|
|
184
|
+
return scope;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The head and base scans of a pull request, run together and over the same
|
|
189
|
+
* scope, and the delta between them (`scanDelta.ts`). Undefined when the
|
|
190
|
+
* checkout has no Terraform. No delta when the base could not be checked out
|
|
191
|
+
* or scanned: the run then gates every finding, as a run without one always has.
|
|
192
|
+
*/
|
|
193
|
+
async function reviewScans(
|
|
194
|
+
ctx: LocalToolContext,
|
|
195
|
+
pr: Pick<PullRequestRunContext, "baseSha" | "headSha">,
|
|
196
|
+
scanners: ScanScanners,
|
|
197
|
+
): Promise<{ head: Concern[]; delta?: GateDelta } | undefined> {
|
|
198
|
+
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
199
|
+
if (walkTfFiles(cwd).length === 0) return undefined;
|
|
200
|
+
const base = checkOutBase(cwd, pr, ctx.tmpdir);
|
|
134
201
|
try {
|
|
135
|
-
const baseCwd = prefix ? join(worktree, prefix) : worktree;
|
|
136
202
|
const changed = diffScope(ctx, cwd, scanners);
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
203
|
+
const scope = base ? reviewScope(ctx, cwd, base) : undefined;
|
|
204
|
+
const started = Date.now();
|
|
205
|
+
const scanBase = async (b: BaseCheckout) =>
|
|
206
|
+
existsSync(b.cwd) ? scanTree(ctx, b.cwd, changed, scanners, reviewedConcern, scope) : [];
|
|
207
|
+
// settled, not `all`: a failed head scan must not leave the base scanning
|
|
208
|
+
// a worktree the `finally` below removes
|
|
209
|
+
const [head, baseScan, secrets] = await Promise.allSettled([
|
|
210
|
+
scanTree(ctx, cwd, changed, scanners, reviewedConcern, scope),
|
|
211
|
+
base ? scanBase(base) : Promise.resolve([]),
|
|
212
|
+
secretsOf(ctx, cwd, base),
|
|
213
|
+
]);
|
|
214
|
+
if (head.status === "rejected") throw head.reason;
|
|
215
|
+
if (!base) return { head: head.value };
|
|
216
|
+
const added = secrets.status === "fulfilled" ? secrets.value : [];
|
|
217
|
+
if (baseScan.status === "rejected") {
|
|
218
|
+
log.warning(
|
|
219
|
+
`» scan delta: base scan failed, every finding is treated as new — ${baseScan.reason}`,
|
|
220
|
+
);
|
|
221
|
+
return { head: head.value };
|
|
154
222
|
}
|
|
155
|
-
const delta =
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
readBase: repoFileReader(baseCwd),
|
|
159
|
-
readHead,
|
|
160
|
-
moved,
|
|
161
|
-
moduleSources: (dir) => modules.get(dir) ?? new Map(),
|
|
162
|
-
headAddresses: (dir) => addresses.get(dir) ?? new Set(),
|
|
163
|
-
});
|
|
223
|
+
const delta = deltaOf(cwd, base, head.value, baseScan.value);
|
|
224
|
+
// every secret the PR's commits add is introduced by the PR
|
|
225
|
+
delta.introduced.push(...added.map((concern) => ({ concern, address: null })));
|
|
164
226
|
log.info(
|
|
165
227
|
`» scan delta: ${delta.introduced.length} introduced, ${delta.resolved.length} resolved, ` +
|
|
166
|
-
`${delta.unchanged.length} unchanged vs ${mergeBase.slice(0, 7)}
|
|
228
|
+
`${delta.unchanged.length} unchanged vs ${base.mergeBase.slice(0, 7)} ` +
|
|
229
|
+
`(base and head scanned in ${Date.now() - started}ms)`,
|
|
167
230
|
);
|
|
168
|
-
return {
|
|
231
|
+
return {
|
|
232
|
+
head: [...head.value, ...added],
|
|
233
|
+
delta: { ...delta, baseSha: base.mergeBase, headSha: pr.headSha },
|
|
234
|
+
};
|
|
169
235
|
} finally {
|
|
170
|
-
|
|
171
|
-
rmSync(parent, { recursive: true, force: true });
|
|
236
|
+
base?.close();
|
|
172
237
|
}
|
|
173
238
|
}
|
|
174
239
|
|
|
240
|
+
/**
|
|
241
|
+
* The secrets the PR's commits add, when `tools_enabled` names betterleaks
|
|
242
|
+
* (`secretScan.ts`): in every file the PR changes, not only Terraform, with the
|
|
243
|
+
* gate's threshold and the repository's suppressions applied.
|
|
244
|
+
*/
|
|
245
|
+
async function secretsOf(
|
|
246
|
+
ctx: LocalToolContext,
|
|
247
|
+
cwd: string,
|
|
248
|
+
base: BaseCheckout | undefined,
|
|
249
|
+
): Promise<Concern[]> {
|
|
250
|
+
if (!resolveToolSelection(ctx.payload).enabled("betterleaks")) return [];
|
|
251
|
+
if (!base) {
|
|
252
|
+
log.info("» betterleaks: no base commit to scan from — secrets were not scanned");
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
const scan = await scanAddedSecrets(cwd, base.mergeBase, ctx.tmpdir);
|
|
256
|
+
// a scan that did not finish still reports what it found
|
|
257
|
+
if (!scan.ran) log.warning(`» betterleaks did not run fully — ${scan.reason}`);
|
|
258
|
+
log.info(`» betterleaks: ${scan.concerns.length} secret(s) added by this pull request`);
|
|
259
|
+
const threshold = (ctx.payload.severityThreshold as Severity | undefined) ?? "low";
|
|
260
|
+
return annotateConcerns(scan.concerns, {
|
|
261
|
+
cwd,
|
|
262
|
+
minRank: SEVERITY_RANK[threshold],
|
|
263
|
+
isTerraform: () => true,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Match the two scans (`scanDelta.ts`), with what the head's configuration says moved. */
|
|
268
|
+
function deltaOf(cwd: string, base: BaseCheckout, head: Concern[], baseConcerns: Concern[]) {
|
|
269
|
+
const moved = new Map<string, MovedBlock[]>();
|
|
270
|
+
const modules = new Map<string, Map<string, string>>();
|
|
271
|
+
const addresses = new Map<string, Set<string>>();
|
|
272
|
+
const readHead = repoFileReader(cwd);
|
|
273
|
+
for (const rel of walkTfFiles(cwd)) {
|
|
274
|
+
const src = readHead(rel);
|
|
275
|
+
if (src === null) continue;
|
|
276
|
+
const dir = posix.dirname(rel.replace(/\\/g, "/"));
|
|
277
|
+
const key = dir === "." ? "" : dir;
|
|
278
|
+
moved.set(key, [...(moved.get(key) ?? []), ...parseMovedBlocks(src)]);
|
|
279
|
+
modules.set(key, new Map([...(modules.get(key) ?? []), ...localModuleSources(src)]));
|
|
280
|
+
const set = addresses.get(key) ?? new Set<string>();
|
|
281
|
+
for (const a of declaredAddresses(src)) set.add(a);
|
|
282
|
+
addresses.set(key, set);
|
|
283
|
+
}
|
|
284
|
+
return computeScanDelta({
|
|
285
|
+
base: baseConcerns.filter((c) => !c.suppression),
|
|
286
|
+
head: head.filter((c) => !c.suppression),
|
|
287
|
+
readBase: repoFileReader(base.cwd),
|
|
288
|
+
readHead,
|
|
289
|
+
moved,
|
|
290
|
+
moduleSources: (dir) => modules.get(dir) ?? new Map(),
|
|
291
|
+
headAddresses: (dir) => addresses.get(dir) ?? new Set(),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
175
295
|
/**
|
|
176
296
|
* Record the gate scan on the session. A failure leaves `gateConcerns` unset —
|
|
177
297
|
* the outputs then read "not scanned", never a pass — and must not stop the run.
|
|
@@ -189,87 +309,24 @@ export async function captureGateScan(
|
|
|
189
309
|
try {
|
|
190
310
|
await captureGateScanInner(ctx, scanners, review);
|
|
191
311
|
} finally {
|
|
192
|
-
removeNewInitArtefacts(checkout, before);
|
|
312
|
+
removeNewInitArtefacts(checkout, before, "findings gate");
|
|
193
313
|
}
|
|
194
314
|
}
|
|
195
315
|
|
|
196
|
-
const INIT_ARTEFACTS = new Set([".terraform", ".terraform.lock.hcl"]);
|
|
197
|
-
|
|
198
|
-
interface InitArtefacts {
|
|
199
|
-
/** init output git neither tracks nor ignores. */
|
|
200
|
-
untracked: Set<string>;
|
|
201
|
-
/** committed lock files with uncommitted changes. */
|
|
202
|
-
modifiedLocks: Set<string>;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* The `terraform init` output in the checkout, as repo-relative paths; null
|
|
207
|
-
* outside a git checkout. A committed lock file counts once it is modified:
|
|
208
|
-
* init adds the runner's platform hash to one made on another platform (a
|
|
209
|
-
* lock file from a Mac lacks the linux hash).
|
|
210
|
-
*/
|
|
211
|
-
function initArtefacts(cwd: string): InitArtefacts | null {
|
|
212
|
-
const r = run("git", ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], cwd);
|
|
213
|
-
if (r.status !== 0) return null;
|
|
214
|
-
const entries = r.stdout.split("\0");
|
|
215
|
-
const isArtefact = (p: string) => INIT_ARTEFACTS.has(posix.basename(p.replace(/\/$/, "")));
|
|
216
|
-
return {
|
|
217
|
-
untracked: new Set(
|
|
218
|
-
entries
|
|
219
|
-
.filter((e) => e.startsWith("?? "))
|
|
220
|
-
.map((e) => e.slice(3))
|
|
221
|
-
.filter(isArtefact),
|
|
222
|
-
),
|
|
223
|
-
modifiedLocks: new Set(
|
|
224
|
-
entries
|
|
225
|
-
.filter((e) => e[1] === "M" && e[2] === " ")
|
|
226
|
-
.map((e) => e.slice(3))
|
|
227
|
-
.filter((p) => posix.basename(p) === ".terraform.lock.hcl"),
|
|
228
|
-
),
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Undo what the gate scan's init did to the checkout: remove the artefacts it
|
|
234
|
-
* created and restore the committed lock files it changed. Validate inits every
|
|
235
|
-
* root in place, and a checkout that is dirty before the agent starts makes
|
|
236
|
-
* `checkout_pr` refuse — or, on a committing run, ships the change in the fix.
|
|
237
|
-
* What was there before the scan is left alone.
|
|
238
|
-
*/
|
|
239
|
-
function removeNewInitArtefacts(cwd: string, before: InitArtefacts | null): void {
|
|
240
|
-
if (!before) return;
|
|
241
|
-
const top = run("git", ["rev-parse", "--show-toplevel"], cwd).stdout.trim();
|
|
242
|
-
const after = initArtefacts(cwd);
|
|
243
|
-
const created = [...(after?.untracked ?? [])].filter((p) => !before.untracked.has(p));
|
|
244
|
-
const changed = [...(after?.modifiedLocks ?? [])].filter((p) => !before.modifiedLocks.has(p));
|
|
245
|
-
if (!top || created.length + changed.length === 0) return;
|
|
246
|
-
for (const p of created) rmSync(join(top, p), { recursive: true, force: true });
|
|
247
|
-
if (changed.length > 0) run("git", ["checkout", "--", ...changed], top);
|
|
248
|
-
resetTerraformInitCache();
|
|
249
|
-
log.info(
|
|
250
|
-
`» findings gate: removed ${created.length} terraform init artefact(s) from the checkout` +
|
|
251
|
-
(changed.length ? `, restored ${changed.length} committed lock file(s)` : ""),
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
316
|
async function captureGateScanInner(
|
|
256
317
|
ctx: LocalToolContext,
|
|
257
318
|
scanners: ScanScanners,
|
|
258
319
|
review: Pick<PullRequestRunContext, "baseSha" | "headSha"> | undefined,
|
|
259
320
|
): Promise<void> {
|
|
260
321
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
} catch (error) {
|
|
270
|
-
log.warning(`» scan delta: base scan failed, every finding is treated as new — ${error}`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
322
|
+
// a review scans its base too, and the gate then judges only what the PR
|
|
323
|
+
// introduced
|
|
324
|
+
const scanned: { head: Concern[]; delta?: GateDelta } | undefined = review
|
|
325
|
+
? await reviewScans(ctx, review, scanners)
|
|
326
|
+
: await runGateScan(ctx, scanners).then((head) => head && { head });
|
|
327
|
+
if (!scanned) return;
|
|
328
|
+
const concerns = scanned.head;
|
|
329
|
+
if (scanned.delta) scanSessionOf(ctx.toolState).gateDelta = scanned.delta;
|
|
273
330
|
const session = scanSessionOf(ctx.toolState);
|
|
274
331
|
// the baseline is part of the verdict, so it is resolved NOW too — from the
|
|
275
332
|
// tree and git state as invoked, before the agent can change either.
|
|
@@ -297,7 +354,13 @@ async function captureGateScanInner(
|
|
|
297
354
|
export async function scanForProof(
|
|
298
355
|
ctx: LocalToolContext,
|
|
299
356
|
cwd: string,
|
|
357
|
+
files?: string[],
|
|
300
358
|
scanners: ScanScanners = realScanners,
|
|
301
359
|
): Promise<Concern[]> {
|
|
302
|
-
|
|
360
|
+
const scope = files
|
|
361
|
+
? (scopeFor([treeLayout(cwd, ctx.payload.varFiles)], files) ?? undefined)
|
|
362
|
+
: undefined;
|
|
363
|
+
return (await scanTree(ctx, cwd, null, scanners, reviewedConcern, scope)).filter(
|
|
364
|
+
(c) => !c.suppression,
|
|
365
|
+
);
|
|
303
366
|
}
|
package/src/mcp/terraform/hcl.ts
CHANGED
|
@@ -482,9 +482,34 @@ export function parseBlocks(hcl: string, keywords: readonly string[]): HclBlock[
|
|
|
482
482
|
return out;
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
/**
|
|
486
|
+
* One block label: a quoted string or, as the HCL grammar also allows, a bare
|
|
487
|
+
* identifier — `resource aws_vpc main {` is the same block as
|
|
488
|
+
* `resource "aws_vpc" "main" {`, and pre-0.12 code is full of it. A reader that
|
|
489
|
+
* knew only the quoted form saw none of those resources, so a proof read a
|
|
490
|
+
* `terraform fmt` that quoted them as resources being ADDED.
|
|
491
|
+
*/
|
|
492
|
+
export const BLOCK_LABEL = String.raw`(?:"[^"\n]*"|[A-Za-z_][A-Za-z0-9_-]*)`;
|
|
493
|
+
|
|
494
|
+
/** a label as written, without its quotes. */
|
|
495
|
+
export function unquoteLabel(label: string): string {
|
|
496
|
+
return label.startsWith('"') ? label.slice(1, -1) : label;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** a `resource <type> <name> {` header in either label style; groups 1 and 2
|
|
500
|
+
* are the labels as written ({@link unquoteLabel}). */
|
|
501
|
+
export const RESOURCE_HEADER = String.raw`(?:^|\n)\s*resource\s+(${BLOCK_LABEL})\s+(${BLOCK_LABEL})\s*\{`;
|
|
502
|
+
|
|
503
|
+
/** a `module <name> {` header in either label style; group 1 is the label as written. */
|
|
504
|
+
export const MODULE_HEADER = String.raw`(?:^|\n)\s*module\s+(${BLOCK_LABEL})\s*\{`;
|
|
505
|
+
|
|
485
506
|
function parseBlocksUncached(hcl: string, alternation: string): HclBlock[] {
|
|
486
507
|
const out: HclBlock[] = [];
|
|
487
|
-
const re = new RegExp(
|
|
508
|
+
const re = new RegExp(
|
|
509
|
+
`(?:^|\\n)[ \\t]*(${alternation})((?:[ \\t]+${BLOCK_LABEL})*)[ \\t]*\\{`,
|
|
510
|
+
"g",
|
|
511
|
+
);
|
|
512
|
+
const labelRe = new RegExp(BLOCK_LABEL, "g");
|
|
488
513
|
|
|
489
514
|
// The shared index rather than a forward counter of this pass's own: five
|
|
490
515
|
// passes over one file used to walk its newlines five times over, and the
|
|
@@ -495,7 +520,7 @@ function parseBlocksUncached(hcl: string, alternation: string): HclBlock[] {
|
|
|
495
520
|
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
|
|
496
521
|
while ((m = re.exec(hcl)) !== null) {
|
|
497
522
|
const keyword = m[1] ?? "";
|
|
498
|
-
const labels = [...(m[2] ?? "").matchAll(
|
|
523
|
+
const labels = [...(m[2] ?? "").matchAll(labelRe)].map((l) => unquoteLabel(l[0]));
|
|
499
524
|
const bodyStart = m.index + m[0].length - 1; // the `{` the header ends on
|
|
500
525
|
const body = matchBraceBody(hcl, bodyStart);
|
|
501
526
|
if (!body) break;
|