infraweaver 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +2 -2
  2. package/dist/agents/claude.d.ts +1 -1
  3. package/dist/agents/claudePretoolGate.d.ts +0 -36
  4. package/dist/agents/toolPlaneWatch.d.ts +31 -0
  5. package/dist/cli.mjs +31903 -18736
  6. package/dist/external.d.ts +2 -1
  7. package/dist/index.js +31987 -18749
  8. package/dist/internal.js +132 -52
  9. package/dist/mcp/baseBranch.d.ts +41 -0
  10. package/dist/mcp/changeSummary.d.ts +1 -1
  11. package/dist/mcp/findingAudit.d.ts +2 -0
  12. package/dist/mcp/guardrails.d.ts +38 -0
  13. package/dist/mcp/localContext.d.ts +1 -1
  14. package/dist/mcp/moduleExtraction.d.ts +5 -0
  15. package/dist/mcp/pr.d.ts +0 -31
  16. package/dist/mcp/reproveSuggestions.d.ts +27 -0
  17. package/dist/mcp/review.d.ts +126 -2
  18. package/dist/mcp/reviewCommentLimit.d.ts +17 -0
  19. package/dist/mcp/reviewComments.d.ts +9 -5
  20. package/dist/mcp/reviewDedup.d.ts +1 -0
  21. package/dist/mcp/reviewMarkers.d.ts +10 -0
  22. package/dist/mcp/reviewProvenance.d.ts +20 -6
  23. package/dist/mcp/shared.d.ts +15 -0
  24. package/dist/mcp/suggestionProver.d.ts +3 -2
  25. package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
  26. package/dist/mcp/terraform/consolidation.d.ts +26 -0
  27. package/dist/mcp/terraform/cost.d.ts +2 -2
  28. package/dist/mcp/terraform/currency.d.ts +11 -0
  29. package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
  30. package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
  31. package/dist/mcp/terraform/evidence.d.ts +10 -0
  32. package/dist/mcp/terraform/gateScan.d.ts +2 -16
  33. package/dist/mcp/terraform/hcl.d.ts +15 -0
  34. package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
  35. package/dist/mcp/terraform/localModules.d.ts +29 -0
  36. package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
  37. package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
  38. package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
  39. package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
  40. package/dist/mcp/terraform/refactor/references.d.ts +15 -0
  41. package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
  42. package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
  43. package/dist/mcp/terraform/scanDelta.d.ts +28 -0
  44. package/dist/mcp/terraform/scanSession.d.ts +13 -0
  45. package/dist/mcp/terraform/scanners.d.ts +67 -18
  46. package/dist/mcp/terraform/secretScan.d.ts +19 -0
  47. package/dist/mcp/terraform/standardsReport.d.ts +1 -1
  48. package/dist/mcp/terraform/tools/plan.d.ts +1 -1
  49. package/dist/mcp/terraform/tools/scan.d.ts +7 -7
  50. package/dist/mcp/terraform/tools/validate.d.ts +7 -2
  51. package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
  52. package/dist/mcp/terraform/treeLayout.d.ts +57 -0
  53. package/dist/mcp/terraform/types.d.ts +1 -1
  54. package/dist/phases/applyPlan.d.ts +0 -2
  55. package/dist/toolState.d.ts +3 -0
  56. package/dist/utils/agent/codexOAuth.d.ts +1 -1
  57. package/dist/utils/cli.d.ts +1 -7
  58. package/dist/utils/cloud/cloudReport.d.ts +1 -1
  59. package/dist/utils/config/baseRefConfig.d.ts +1 -1
  60. package/dist/utils/config/infraweaverConfig.d.ts +1 -1
  61. package/dist/utils/config/payload.d.ts +5 -0
  62. package/dist/utils/markdownTable.d.ts +6 -0
  63. package/dist/utils/setup/toolLicensing.d.ts +1 -1
  64. package/dist/utils/unifiedDiff.d.ts +69 -0
  65. package/package.json +34 -28
  66. package/src/agents/claude.ts +48 -13
  67. package/src/agents/claudePretoolGate.ts +9 -17
  68. package/src/agents/sessionLabeler.ts +3 -3
  69. package/src/agents/toolPlaneWatch.ts +71 -0
  70. package/src/external.ts +3 -1
  71. package/src/mcp/assess.ts +8 -8
  72. package/src/mcp/baseBranch.ts +96 -0
  73. package/src/mcp/changeSummary.ts +10 -11
  74. package/src/mcp/checkSuite.ts +7 -10
  75. package/src/mcp/checkout.ts +13 -40
  76. package/src/mcp/crosswalk.ts +5 -0
  77. package/src/mcp/findingAudit.ts +1 -1
  78. package/src/mcp/git.ts +44 -2
  79. package/src/mcp/guardrails.ts +188 -36
  80. package/src/mcp/localContext.ts +2 -0
  81. package/src/mcp/localServer.ts +1 -0
  82. package/src/mcp/moduleExtraction.ts +17 -5
  83. package/src/mcp/moduleTests.ts +9 -2
  84. package/src/mcp/modules.ts +10 -7
  85. package/src/mcp/pr.ts +17 -65
  86. package/src/mcp/reproveSuggestions.ts +86 -0
  87. package/src/mcp/review.ts +249 -75
  88. package/src/mcp/reviewAnchor.ts +11 -23
  89. package/src/mcp/reviewCommentLimit.ts +79 -0
  90. package/src/mcp/reviewComments.ts +34 -81
  91. package/src/mcp/reviewDedup.ts +1 -1
  92. package/src/mcp/reviewMarkers.ts +25 -2
  93. package/src/mcp/reviewProvenance.ts +56 -21
  94. package/src/mcp/roots.ts +3 -2
  95. package/src/mcp/server.ts +2 -1
  96. package/src/mcp/shared.ts +40 -1
  97. package/src/mcp/shell.ts +27 -28
  98. package/src/mcp/suggestionProver.ts +8 -4
  99. package/src/mcp/terraform/checkovSeverities.ts +1156 -0
  100. package/src/mcp/terraform/concernResult.ts +2 -1
  101. package/src/mcp/terraform/consolidation.ts +232 -19
  102. package/src/mcp/terraform/cost.ts +16 -7
  103. package/src/mcp/terraform/cspm.ts +8 -4
  104. package/src/mcp/terraform/currency.ts +28 -12
  105. package/src/mcp/terraform/decisions.ts +1 -1
  106. package/src/mcp/terraform/deltaSummary.ts +50 -1
  107. package/src/mcp/terraform/emittedArtefacts.ts +63 -0
  108. package/src/mcp/terraform/evidence.ts +23 -3
  109. package/src/mcp/terraform/gateScan.ts +191 -128
  110. package/src/mcp/terraform/hcl.ts +27 -2
  111. package/src/mcp/terraform/idleFloor.ts +2 -1
  112. package/src/mcp/terraform/initArtefacts.ts +76 -0
  113. package/src/mcp/terraform/localModules.ts +112 -0
  114. package/src/mcp/terraform/moduleDocs.ts +2 -5
  115. package/src/mcp/terraform/moduleFindings.ts +102 -0
  116. package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
  117. package/src/mcp/terraform/planPairs.ts +2 -20
  118. package/src/mcp/terraform/policyGate.ts +0 -15
  119. package/src/mcp/terraform/refactor/attribution.ts +39 -5
  120. package/src/mcp/terraform/refactor/equivalence.ts +49 -10
  121. package/src/mcp/terraform/refactor/expressions.ts +323 -0
  122. package/src/mcp/terraform/refactor/references.ts +101 -0
  123. package/src/mcp/terraform/refactor/resources.ts +79 -7
  124. package/src/mcp/terraform/refactor/substitution.ts +852 -256
  125. package/src/mcp/terraform/scanDelta.ts +34 -0
  126. package/src/mcp/terraform/scanSession.ts +30 -0
  127. package/src/mcp/terraform/scannerCache.ts +4 -0
  128. package/src/mcp/terraform/scanners.ts +553 -233
  129. package/src/mcp/terraform/secretScan.ts +131 -0
  130. package/src/mcp/terraform/standardsReport.ts +14 -13
  131. package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
  132. package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
  133. package/src/mcp/terraform/tools/emitOscal.ts +3 -2
  134. package/src/mcp/terraform/tools/emitVex.ts +10 -4
  135. package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
  136. package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
  137. package/src/mcp/terraform/tools/plan.ts +30 -17
  138. package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
  139. package/src/mcp/terraform/tools/scan.ts +5 -3
  140. package/src/mcp/terraform/tools/standardsReport.ts +1 -1
  141. package/src/mcp/terraform/tools/validate.ts +20 -5
  142. package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
  143. package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
  144. package/src/mcp/terraform/treeLayout.ts +378 -0
  145. package/src/mcp/terraform/types.ts +4 -1
  146. package/src/mcp/terraform/versionRequirements.ts +11 -2
  147. package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
  148. package/src/modes/modernize-deprecated.ts +2 -2
  149. package/src/modes/refactor.ts +9 -9
  150. package/src/modes/remediate.ts +1 -1
  151. package/src/modes/terraform-code-review.ts +6 -3
  152. package/src/modes/update-dependencies.ts +6 -6
  153. package/src/phases/applyPlan.ts +33 -14
  154. package/src/phases/applySuggestions.ts +2 -2
  155. package/src/phases/runAgentWithWatchdogs.ts +2 -1
  156. package/src/toolState.ts +7 -0
  157. package/src/utils/agent/codexOAuth.ts +7 -7
  158. package/src/utils/agent/openCodeModels.ts +4 -6
  159. package/src/utils/cli.ts +1 -31
  160. package/src/utils/cloud/runContext.ts +1 -8
  161. package/src/utils/config/baseRefConfig.ts +6 -0
  162. package/src/utils/config/infraweaverConfig.ts +2 -0
  163. package/src/utils/config/payload.ts +41 -0
  164. package/src/utils/github/assets.ts +1 -6
  165. package/src/utils/github/github.ts +42 -150
  166. package/src/utils/github/token.ts +6 -11
  167. package/src/utils/log.ts +4 -5
  168. package/src/utils/markdownTable.ts +11 -0
  169. package/src/utils/prompt/changeImpact.ts +18 -26
  170. package/src/utils/prompt/instructions.ts +1 -1
  171. package/src/utils/prompt/promptDirectives.ts +1 -1
  172. package/src/utils/prompt/toolSelection.ts +11 -2
  173. package/src/utils/setup/toolLicensing.ts +7 -0
  174. package/src/utils/telemetry.ts +2 -1
  175. package/src/utils/unifiedDiff.ts +120 -0
@@ -1,11 +1,27 @@
1
- import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { join, posix } from "node:path";
3
+ import { join, posix, relative } from "node:path";
4
+ import { errorMessage } from "@infraweaver-io/core/errors";
4
5
  import { type } from "arktype";
5
- import { MAX_TF_TOTAL_BYTES, readTextCapped, walkTfFiles } from "#app/mcp/modules";
6
+ import {
7
+ MAX_TF_TOTAL_BYTES,
8
+ parseModuleBlocks,
9
+ readTextCapped,
10
+ walkTfFiles,
11
+ } from "#app/mcp/modules";
6
12
  import { loadProvidersSchema, unknownArgsForResource } from "#app/mcp/providerSchema";
7
- import { matchBraceBody, skipNonCode } from "#app/mcp/terraform/hcl";
13
+ import { CHECKOV_SEVERITIES } from "#app/mcp/terraform/checkovSeverities";
14
+ import { matchBraceBody, RESOURCE_HEADER, skipNonCode, unquoteLabel } from "#app/mcp/terraform/hcl";
8
15
  import { checkovTypeToLanguage, trivyTypeToLanguage } from "#app/mcp/terraform/iacLanguages";
16
+ import { configFilesIn, localModuleClosure } from "#app/mcp/terraform/localModules";
17
+ import {
18
+ type FetchDir,
19
+ isFetchedModulePath,
20
+ type ModuleFinding,
21
+ moduleCallLine,
22
+ relocateToCalls,
23
+ topModuleName,
24
+ } from "#app/mcp/terraform/moduleFindings";
9
25
  import { scanNative } from "#app/mcp/terraform/nativeScan";
10
26
  import {
11
27
  type ResolvedRoot,
@@ -16,14 +32,16 @@ import {
16
32
  import { egressEnvFor, type ToolsNetworkDirective } from "#app/mcp/terraform/scannerEgress";
17
33
  import { parseScannerJson } from "#app/mcp/terraform/scannerJson";
18
34
  import { egressIsolationOf, scannerSpawn } from "#app/mcp/terraform/scannerNetns";
19
- import {
20
- run,
21
- runAsync,
22
- type RunResult,
23
- runTf,
24
- runTfAsync,
25
- } from "#app/mcp/terraform/subprocess";
35
+ import { type RunResult, run, runAsync, runTfAsync } from "#app/mcp/terraform/subprocess";
26
36
  import { assertToolchainIntegrity } from "#app/mcp/terraform/toolchainIntegrity";
37
+ import {
38
+ isWithin,
39
+ type RootLayout,
40
+ skipDirsFor,
41
+ type TreeLayout,
42
+ treeLayout,
43
+ type VarFileMapping,
44
+ } from "#app/mcp/terraform/treeLayout";
27
45
  import {
28
46
  buildConcern,
29
47
  type Concern,
@@ -39,20 +57,41 @@ import { log } from "#app/utils/cli";
39
57
  import { mapWithConcurrency } from "#app/utils/concurrency";
40
58
  import { type ResolvedToolSelection, scannerToolId } from "#app/utils/prompt/toolSelection";
41
59
 
42
- // dirs already `terraform init`-ed this process, so repeated scans don't re-init.
43
- const initedDirs = new Set<string>();
60
+ // dirs already `terraform init`-ed this process, each with the module calls and
61
+ // provider requirements it was initialised for, so repeated scans don't re-init
62
+ // an unchanged root but one an edit gave a new module call is.
63
+ const initedDirs = new Map<string, string>();
44
64
 
45
65
  /**
46
- * Run `terraform init -backend=false` once per dir so `terraform validate` has
47
- * provider schemas to check against (Bug 3 / gap B). Without init, validate only
48
- * emits "missing required provider" — which VALIDATE_NOISE drops — so it was
49
- * effectively inert. `-backend=false` avoids needing real backend credentials;
50
- * `-input=false` keeps it non-interactive. Network-dependent and best-effort: if
51
- * it fails (offline, private module, etc.) validate still runs, just shallow.
66
+ * What `terraform init` installs for a root: every module call and provider
67
+ * requirement across the root and the local modules it reaches. A refactor adds
68
+ * a module call between two validates; the init the first one ran never
69
+ * installed it, and validate's "Module not installed" is dropped as noise.
52
70
  */
71
+ function initFingerprint(absDir: string): string {
72
+ const parts: string[] = [];
73
+ for (const dir of localModuleClosure(absDir).dirs) {
74
+ for (const file of configFilesIn(dir)) {
75
+ const text = readTextCapped(file) ?? "";
76
+ if (file.endsWith(".json")) {
77
+ parts.push(`${file}\n${text}`);
78
+ continue;
79
+ }
80
+ for (const m of parseModuleBlocks(text)) {
81
+ parts.push(`${file}|module|${m.name}|${m.source}|${m.version ?? ""}`);
82
+ }
83
+ for (const p of parseRequiredProviders(text)) {
84
+ parts.push(`${file}|provider|${p.name}|${p.source ?? ""}|${p.version ?? ""}`);
85
+ }
86
+ }
87
+ }
88
+ return parts.sort().join("\n");
89
+ }
90
+
53
91
  /** Forget every init, so the next validate re-runs it — after its artefacts were removed. */
54
92
  export function resetTerraformInitCache(): void {
55
93
  initedDirs.clear();
94
+ initFailedDirs.clear();
56
95
  }
57
96
 
58
97
  let pluginCacheDir: string | undefined;
@@ -62,14 +101,21 @@ let pluginCacheDir: string | undefined;
62
101
  * cache for the whole process. A repository with many roots otherwise downloads
63
102
  * the same provider once per root, and a PR review inits every root twice (base
64
103
  * and head). The cache is private to the process (a fresh `mkdtemp`) and removed
65
- * on exit. Using it without a lock file checksum is admitted: the lock file
104
+ * on exit, unless the workflow names one in `TF_PLUGIN_CACHE_DIR` to keep it
105
+ * between runs. Using it without a lock file checksum is admitted: the lock file
66
106
  * init writes here is an artefact the scan removes, not a dependency decision.
67
107
  */
68
108
  function initEnv(extraEnv?: Record<string, string>): Record<string, string> {
69
109
  if (!pluginCacheDir) {
70
- const dir = mkdtempSync(join(tmpdir(), "iw-tf-plugins-"));
71
- pluginCacheDir = dir;
72
- process.once("exit", () => rmSync(dir, { recursive: true, force: true }));
110
+ const kept = process.env.TF_PLUGIN_CACHE_DIR;
111
+ if (kept) {
112
+ mkdirSync(kept, { recursive: true });
113
+ pluginCacheDir = kept;
114
+ } else {
115
+ const dir = mkdtempSync(join(tmpdir(), "iw-tf-plugins-"));
116
+ pluginCacheDir = dir;
117
+ process.once("exit", () => rmSync(dir, { recursive: true, force: true }));
118
+ }
73
119
  }
74
120
  return {
75
121
  TF_PLUGIN_CACHE_DIR: pluginCacheDir,
@@ -78,25 +124,62 @@ function initEnv(extraEnv?: Record<string, string>): Record<string, string> {
78
124
  };
79
125
  }
80
126
 
81
- function ensureTerraformInit(cwd: string, extraEnv?: Record<string, string>): void {
82
- if (initedDirs.has(cwd)) return;
83
- // `extraEnv` carries the optional module-fetch credential so init can
84
- // resolve a private cross-repo `git::` module; absent for the common case.
85
- // runTf falls back to `tofu` when `terraform` isn't installed.
86
- const r = runTf(["init", "-backend=false", "-input=false", "-no-color"], cwd, initEnv(extraEnv));
87
- // mark done even on non-zero: a failed init won't succeed on retry within the
88
- // same run, and we don't want to re-run it for every scanner call.
89
- initedDirs.add(cwd);
127
+ // roots whose last init did not complete, with what init said: validate there
128
+ // reads only what it can without the modules and providers init would have
129
+ // installed.
130
+ const initFailedDirs = new Map<string, string>();
131
+
132
+ /** init's own `Error:` headline and the line that explains it — "no available
133
+ * releases match the given constraints >= 6.42.0, ~> 5.0" is the whole story
134
+ * of a module bump the provider pin cannot satisfy. */
135
+ function initErrorOf(r: RunResult): string {
136
+ const lines = r.stderr.split("\n").map((l) => l.replace(/^[│╷╵\s]+/, "").trim());
137
+ const at = lines.findIndex((l) => l.startsWith("Error:"));
138
+ if (at === -1) return `terraform init exited ${r.status}`;
139
+ // the explanation is the first paragraph after it, which terraform wraps
140
+ const rest = lines.slice(at + 1);
141
+ const start = rest.findIndex((l) => l !== "");
142
+ const end = start === -1 ? -1 : rest.indexOf("", start);
143
+ const detail = start === -1 ? "" : rest.slice(start, end === -1 ? undefined : end).join(" ");
144
+ return [lines[at], detail].filter(Boolean).join(" — ").slice(0, 400);
145
+ }
146
+
147
+ function recordInit(dir: string, fingerprint: string, r: RunResult): void {
148
+ // mark done even on non-zero: a failed init won't succeed on retry until the
149
+ // module calls or provider requirements change, and we don't want to re-run
150
+ // it for every scanner call.
151
+ initedDirs.set(dir, fingerprint);
90
152
  if (r.status !== 0 && !r.missing) {
153
+ initFailedDirs.set(dir, initErrorOf(r));
91
154
  log.info(`» terraform init (for validate) did not complete cleanly — validate may be shallow`);
155
+ } else {
156
+ initFailedDirs.delete(dir);
92
157
  }
93
158
  }
94
159
 
95
160
  /**
96
- * `ensureTerraformInit` for every root, awaited — the ONE step in a scan that
97
- * writes into the tree being scanned (`.terraform/modules`, `.terraform.lock.hcl`).
161
+ * The roots under `cwd` whose last `terraform init` failed, with init's error.
162
+ * Their validate is shallow — "Module not installed" is all it can say, and that
163
+ * is dropped as noise — so nothing that leans on validate may count them as
164
+ * checked.
165
+ */
166
+ export function initFailures(cwd: string): { root: string; error: string }[] {
167
+ return resolveRoots(cwd).flatMap((r) => {
168
+ const error = initFailedDirs.get(r.absDir);
169
+ return error === undefined ? [] : [{ root: r.relDir || ".", error }];
170
+ });
171
+ }
172
+
173
+ /** {@link initFailures}, the roots only. */
174
+ export function initFailedRoots(cwd: string): string[] {
175
+ return initFailures(cwd).map((f) => f.root);
176
+ }
177
+
178
+ /**
179
+ * `initRoots`, queued and awaited — the ONE step in a scan that writes into the
180
+ * tree being scanned (`.terraform/modules`, `.terraform.lock.hcl`).
98
181
  *
99
- * It is hoisted out of `scanValidate` and awaited before any scanner starts so
182
+ * It is awaited before any scanner starts so
100
183
  * that the parallel phase reads a tree in exactly the state the old sequential
101
184
  * order produced. Racing init against trivy/checkov would let them read a
102
185
  * half-populated module directory, and this product hashes findings into ids —
@@ -107,25 +190,42 @@ async function primeTerraformInit(
107
190
  roots: ResolvedRoot[],
108
191
  extraEnv?: Record<string, string>,
109
192
  ): Promise<void> {
193
+ // one init at a time across concurrent scans (a review scans its base and
194
+ // head together): they share the provider plugin cache, which Terraform does
195
+ // not make safe for concurrent writers.
196
+ const mine = initQueue.then(() => initRoots(roots, extraEnv));
197
+ initQueue = mine.catch(() => {});
198
+ return mine;
199
+ }
200
+
201
+ let initQueue: Promise<void> = Promise.resolve();
202
+
203
+ /**
204
+ * Run `terraform init -backend=false` once per root so `terraform validate` has
205
+ * provider schemas to check against. Without init, validate only emits "missing
206
+ * required provider" — which VALIDATE_NOISE drops — so it was effectively inert.
207
+ * `-backend=false` avoids needing real backend credentials; `-input=false` keeps
208
+ * it non-interactive. Network-dependent and best-effort: if it fails (offline,
209
+ * private module, etc.) validate still runs, just shallow. `extraEnv` carries
210
+ * the optional module-fetch credential so a private cross-repo `git::` module
211
+ * resolves.
212
+ */
213
+ async function initRoots(roots: ResolvedRoot[], extraEnv?: Record<string, string>): Promise<void> {
110
214
  for (const root of roots) {
111
- if (initedDirs.has(root.absDir)) continue;
215
+ const fingerprint = initFingerprint(root.absDir);
216
+ if (initedDirs.get(root.absDir) === fingerprint) continue;
112
217
  const r = await runTfAsync(
113
218
  ["init", "-backend=false", "-input=false", "-no-color"],
114
219
  root.absDir,
115
220
  initEnv(extraEnv),
116
221
  );
117
- initedDirs.add(root.absDir);
118
- if (r.status !== 0 && !r.missing) {
119
- log.info(
120
- `» terraform init (for validate) did not complete cleanly — validate may be shallow`,
121
- );
122
- }
222
+ recordInit(root.absDir, fingerprint, r);
123
223
  }
124
224
  }
125
225
 
126
226
  // --- terraform fmt -------------------------------------------------------
127
227
 
128
- /** shape a `terraform fmt` result into an outcome; shared by both call styles. */
228
+ /** shape a `terraform fmt` result into an outcome. */
129
229
  function fmtOutcome(r: RunResult, cwd: string): ScannerOutcome {
130
230
  if (r.missing) return skipped("terraform-fmt", "terraform (or tofu) not installed");
131
231
  // exit 0 = all formatted; exit 3 = files need formatting (lists them on stdout);
@@ -138,19 +238,16 @@ function fmtOutcome(r: RunResult, cwd: string): ScannerOutcome {
138
238
  };
139
239
  }
140
240
 
141
- export function scanFmt(cwd: string): ScannerOutcome {
142
- // `terraform` when installed, else `tofu` — OpenTofu-only runners keep fmt
143
- // coverage (identical CLI surface; the source label stays "terraform-fmt" so
144
- // rule ids and content hashes never fork on the binary name).
145
- return fmtOutcome(runTf(["fmt", "-check", "-recursive", "-list=true"], cwd), cwd);
146
- }
147
-
148
241
  /**
242
+ * `terraform` when installed, else `tofu` — OpenTofu-only runners keep fmt
243
+ * coverage (identical CLI surface; the source label stays "terraform-fmt" so
244
+ * rule ids and content hashes never fork on the binary name).
245
+ *
149
246
  * Safe to run alongside `terraform init`: `fmt -recursive` skips dot-directories,
150
247
  * so it never reads the `.terraform/` tree init is writing (verified against the
151
248
  * installed CLI — an unformatted file planted under `.terraform/` is not reported).
152
249
  */
153
- async function scanFmtAsync(cwd: string): Promise<ScannerOutcome> {
250
+ export async function scanFmt(cwd: string): Promise<ScannerOutcome> {
154
251
  return fmtOutcome(await runTfAsync(["fmt", "-check", "-recursive", "-list=true"], cwd), cwd);
155
252
  }
156
253
 
@@ -193,35 +290,41 @@ const VALIDATE_NOISE = [
193
290
  "could not load plugin",
194
291
  ];
195
292
 
196
- /** run `terraform validate` in one root and return concerns re-based onto cwd. */
197
- function scanValidateRoot(root: ResolvedRoot, extraEnv?: Record<string, string>): ScannerOutcome {
198
- ensureTerraformInit(root.absDir, extraEnv);
199
- const r = runTf(["validate", "-json"], root.absDir);
200
- if (r.missing) return skipped("terraform-validate", "terraform (or tofu) not installed");
201
- try {
202
- const concerns = parseValidateOutput(r.stdout, root.absDir).map((c) =>
203
- rebaseConcern(c, root.relDir),
204
- );
205
- return { source: "terraform-validate", ran: true, concerns };
206
- } catch {
207
- // terraform ran but emitted output we couldn't parse — a real CLI-level
208
- // failure (corrupted .terraform, a crash, an ancient terraform without
209
- // `-json`), NOT a clean tree. Flag it as unvalidated so the tool fails
210
- // closed rather than reporting this root as passing.
211
- return {
212
- ...skipped("terraform-validate", "could not parse `terraform validate -json` output"),
213
- unvalidated: 1,
214
- };
215
- }
216
- }
217
-
218
293
  /**
219
- * Run `terraform validate` across EVERY root and aggregate. `validate` is the
220
- * one scanner that's per-root (fmt/tflint/trivy/checkov are recursive over the
221
- * whole tree), so a multi-root repo only catches subdir-root validate errors
222
- * when we visit each root.
294
+ * A local module source that resolves to no Terraform configuration, for every
295
+ * call the root reaches. Validate would report it only as "Module not
296
+ * installed", which is dropped as noise because an init that failed for an
297
+ * environmental reason says the same; a local source involves no environment.
223
298
  */
224
- /** aggregate per-root validate outcomes; shared by both call styles. */
299
+ function missingLocalModules(root: ResolvedRoot): Concern[] {
300
+ return localModuleClosure(root.absDir)
301
+ .calls.filter((call) => !call.exists)
302
+ .map((call) => {
303
+ const file = posix.normalize(relative(root.absDir, call.file).replace(/\\/g, "/"));
304
+ const target = posix.normalize(
305
+ `${root.relDir || "."}/${relative(root.absDir, call.dir).replace(/\\/g, "/")}`,
306
+ );
307
+ return rebaseConcern(
308
+ buildConcern({
309
+ source: "terraform-validate",
310
+ rule: "Unreadable module directory",
311
+ file,
312
+ line: call.line,
313
+ severity: "high",
314
+ category: "correctness",
315
+ evidence:
316
+ `module "${call.name}" has source "${call.source}", which resolves to ${target} — ` +
317
+ "no Terraform configuration there.",
318
+ remediation_hint:
319
+ "A local module source is relative to the directory of the file that declares the " +
320
+ "call: from env/dev, a module at modules/s3 is `../../modules/s3`.",
321
+ }),
322
+ root.relDir,
323
+ );
324
+ });
325
+ }
326
+
327
+ /** aggregate per-root validate outcomes. */
225
328
  function validateOutcome(outcomes: ScannerOutcome[]): ScannerOutcome {
226
329
  const concerns: Concern[] = [];
227
330
  let anyRan = false;
@@ -257,16 +360,21 @@ function validateOutcome(outcomes: ScannerOutcome[]): ScannerOutcome {
257
360
  * `primeTerraformInit` has already done it for every root before the parallel
258
361
  * phase starts. Read-only from here, so the roots run concurrently.
259
362
  */
260
- async function scanValidateAsync(roots: ResolvedRoot[]): Promise<ScannerOutcome> {
363
+ async function validateRoots(roots: ResolvedRoot[]): Promise<ScannerOutcome> {
261
364
  const outcomes = await mapWithConcurrency(roots, 4, async (root) => {
262
365
  const r = await runTfAsync(["validate", "-json"], root.absDir);
263
366
  if (r.missing) return skipped("terraform-validate", "terraform (or tofu) not installed");
264
367
  try {
265
- const concerns = parseValidateOutput(r.stdout, root.absDir).map((c) =>
266
- rebaseConcern(c, root.relDir),
267
- );
368
+ const concerns = [
369
+ ...missingLocalModules(root),
370
+ ...parseValidateOutput(r.stdout, root.absDir).map((c) => rebaseConcern(c, root.relDir)),
371
+ ];
268
372
  return { source: "terraform-validate" as const, ran: true, concerns };
269
373
  } catch {
374
+ // terraform ran but emitted output we couldn't parse — a real CLI-level
375
+ // failure (corrupted .terraform, a crash, an ancient terraform without
376
+ // `-json`), NOT a clean tree. Flag it as unvalidated so the tool fails
377
+ // closed rather than reporting this root as passing.
270
378
  return {
271
379
  ...skipped("terraform-validate", "could not parse `terraform validate -json` output"),
272
380
  unvalidated: 1,
@@ -276,36 +384,19 @@ async function scanValidateAsync(roots: ResolvedRoot[]): Promise<ScannerOutcome>
276
384
  return validateOutcome(outcomes);
277
385
  }
278
386
 
279
- export function scanValidate(cwd: string, extraEnv?: Record<string, string>): ScannerOutcome {
387
+ /**
388
+ * Run `terraform validate` across EVERY root and aggregate. `validate` is the
389
+ * one scanner that's per-root (fmt/tflint/trivy/checkov are recursive over the
390
+ * whole tree), so a multi-root repo only catches subdir-root validate errors
391
+ * when we visit each root. Inits first, through the same queue a scan uses.
392
+ */
393
+ export async function scanValidate(
394
+ cwd: string,
395
+ extraEnv?: Record<string, string>,
396
+ ): Promise<ScannerOutcome> {
280
397
  const roots = resolveRoots(cwd);
281
- const concerns: Concern[] = [];
282
- let anyRan = false;
283
- let sawMissing = false;
284
- let unvalidated = 0;
285
- for (const root of roots) {
286
- const outcome = scanValidateRoot(root, extraEnv);
287
- unvalidated += outcome.unvalidated ?? 0;
288
- if (outcome.ran) {
289
- anyRan = true;
290
- concerns.push(...outcome.concerns);
291
- } else if (outcome.skipped_reason?.includes("not installed")) {
292
- sawMissing = true;
293
- }
294
- }
295
- if (!anyRan) {
296
- return sawMissing
297
- ? skipped("terraform-validate", "terraform not installed")
298
- : {
299
- ...skipped("terraform-validate", "could not parse `terraform validate -json` output"),
300
- unvalidated,
301
- };
302
- }
303
- return {
304
- source: "terraform-validate",
305
- ran: true,
306
- concerns: dedupe(concerns),
307
- unvalidated,
308
- };
398
+ await primeTerraformInit(roots, extraEnv);
399
+ return validateRoots(roots);
309
400
  }
310
401
 
311
402
  /** parse `terraform validate -json`; keeps real errors, drops uninitialized-dir noise. */
@@ -501,11 +592,12 @@ const RESOURCE_META_ARGUMENTS: ReadonlySet<string> = new Set([
501
592
  */
502
593
  export function parseResourceArguments(hcl: string): ResourceArguments[] {
503
594
  const out: ResourceArguments[] = [];
504
- const re = /(?:^|\n)\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/g;
595
+ const re = new RegExp(RESOURCE_HEADER, "g");
505
596
  let m: RegExpExecArray | null;
506
597
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
507
598
  while ((m = re.exec(hcl)) !== null) {
508
- const [, resourceType = "", name = ""] = m;
599
+ const resourceType = unquoteLabel(m[1] ?? "");
600
+ const name = unquoteLabel(m[2] ?? "");
509
601
  const braceStart = hcl.indexOf("{", m.index);
510
602
  if (braceStart === -1) break;
511
603
  const body = matchBraceBody(hcl, braceStart);
@@ -688,7 +780,7 @@ const tflintInitedDirs = new Set<string>();
688
780
  * repo. Best-effort and network-dependent: a failed init just leaves tflint
689
781
  * running its core rules, exactly as before.
690
782
  */
691
- function ensureTflintInit(cwd: string, extraEnv?: Record<string, string>): void {
783
+ async function ensureTflintInit(cwd: string, extraEnv?: Record<string, string>): Promise<void> {
692
784
  if (tflintInitedDirs.has(cwd)) return;
693
785
  // mark first: a failed init won't succeed on retry within the same run, and
694
786
  // we don't want to re-attempt the network fetch on every scanner call.
@@ -699,7 +791,7 @@ function ensureTflintInit(cwd: string, extraEnv?: Record<string, string>): void
699
791
  // fetch needs a route, so it never runs in the namespace — which is what makes
700
792
  // a denied tflint `partial` whenever the repo ships a `.tflint.hcl`.
701
793
  const init = scannerSpawn("tflint", ["--init"], extraEnv ?? null, true);
702
- const r = run(init.cmd, init.args, cwd, extraEnv);
794
+ const r = await runAsync(init.cmd, init.args, cwd, extraEnv);
703
795
  if (r.status !== 0 && !r.missing) {
704
796
  log.info(
705
797
  "» tflint --init did not complete cleanly — provider ruleset plugins may be unavailable",
@@ -707,7 +799,7 @@ function ensureTflintInit(cwd: string, extraEnv?: Record<string, string>): void
707
799
  }
708
800
  }
709
801
 
710
- /** shape a tflint result into an outcome; shared by both call styles. */
802
+ /** shape a tflint result into an outcome. */
711
803
  /**
712
804
  * A scanner that exited non-zero with nothing on stdout produced no report — it
713
805
  * crashed, timed out or was killed. The JSON parsers read empty stdout as `{}`,
@@ -716,7 +808,7 @@ function ensureTflintInit(cwd: string, extraEnv?: Record<string, string>): void
716
808
  /** Why a scanner's report could not be read. The scanner then counts as not
717
809
  * run, so the reason must say more than "could not parse". */
718
810
  function unparsedReason(tool: "tflint" | "trivy" | "checkov", error: unknown): string {
719
- const why = (error instanceof Error ? error.message : String(error)).slice(0, 200);
811
+ const why = errorMessage(error).slice(0, 200);
720
812
  return `could not parse ${tool} json output: ${why}`;
721
813
  }
722
814
 
@@ -744,16 +836,7 @@ function tflintOutcome(r: RunResult, cwd: string, egress: unknown): ScannerOutco
744
836
 
745
837
  const TFLINT_SCAN_ARGS = ["--format", "json", "--recursive"];
746
838
 
747
- export function scanTflint(cwd: string, network?: ToolsNetworkDirective): ScannerOutcome {
748
- const egress = egressEnvFor("tflint", network);
749
- assertToolchainIntegrity("tflint", cwd, probeUnder("tflint", egress));
750
- ensureTflintInit(cwd, egress ?? undefined);
751
- const scan = scannerSpawn("tflint", TFLINT_SCAN_ARGS, egress, operatorHostsAdmitted(network));
752
- const r = run(scan.cmd, scan.args, cwd, egress ?? undefined);
753
- return tflintOutcome(r, cwd, egress);
754
- }
755
-
756
- async function scanTflintAsync(
839
+ export async function scanTflint(
757
840
  cwd: string,
758
841
  network?: ToolsNetworkDirective,
759
842
  ): Promise<ScannerOutcome> {
@@ -765,7 +848,7 @@ async function scanTflintAsync(
765
848
  // `tflint --init` installs ruleset plugins outside the scanned tree, and is
766
849
  // awaited before the scan proper, so it neither races the other scanners nor
767
850
  // reorders within this one.
768
- ensureTflintInit(cwd, egress ?? undefined);
851
+ await ensureTflintInit(cwd, egress ?? undefined);
769
852
  const scan = scannerSpawn("tflint", TFLINT_SCAN_ARGS, egress, operatorHostsAdmitted(network));
770
853
  const r = await runAsync(scan.cmd, scan.args, cwd, egress ?? undefined);
771
854
  return tflintOutcome(r, cwd, egress);
@@ -818,8 +901,10 @@ function trivyOutcome(r: RunResult, cwd: string, egress: unknown): ScannerOutcom
818
901
  // tfsec was archived by Aqua and folded into Trivy; `trivy config` is its
819
902
  // maintained successor with a larger ruleset (the AVD-* checks). `--quiet`
820
903
  // keeps Trivy's progress chatter off stdout so the JSON parses cleanly.
821
- async function scanTrivyAsync(
904
+ async function scanTrivy(
822
905
  cwd: string,
906
+ layout: TreeLayout,
907
+ scope: readonly string[] | undefined,
823
908
  network?: ToolsNetworkDirective,
824
909
  ): Promise<ScannerOutcome> {
825
910
  // Supply-chain gate: refuse to execute a known-compromised trivy build
@@ -829,43 +914,100 @@ async function scanTrivyAsync(
829
914
  // `trivy config` pulls its misconfiguration checks bundle from the registries
830
915
  // the overlay admits, so the scan needs a route and a denied trivy stays
831
916
  // `partial`.
832
- const scan = scannerSpawn("trivy", trivyArgs(cwd), egress, true);
833
- const r = await runAsync(scan.cmd, scan.args, cwd, egress ?? undefined);
834
- return trivyOutcome(r, cwd, egress);
917
+ const runTrivy = async (args: string[]) => {
918
+ const scan = scannerSpawn("trivy", args, egress, true);
919
+ return trivyOutcome(await runAsync(scan.cmd, scan.args, cwd, egress ?? undefined), cwd, egress);
920
+ };
921
+ const plan = trivyRuns(layout, scope);
922
+ const [whole, ...perRoot] = plan;
923
+ const outcome = await runTrivy(trivyArgs(whole as TrivyRun));
924
+ if (!outcome.ran) return outcome;
925
+ // one at a time: each run is a few seconds, and the runs of the base and the
926
+ // head of a review already overlap
927
+ const merged = mergeVariableRuns(outcome.concerns, perRoot, async (run) => {
928
+ const r = await runTrivy(trivyArgs(run));
929
+ if (!r.ran) {
930
+ log.warning(`» trivy with ${run.tfVars.join(", ")} did not run — ${r.skipped_reason}`);
931
+ }
932
+ return r.ran ? r.concerns : [];
933
+ });
934
+ return { ...outcome, concerns: await merged };
835
935
  }
836
936
 
837
- /** The variable files Terraform loads by itself in a root module, in its order. */
838
- const AUTO_TFVARS = /^(?:terraform\.tfvars|.+\.auto\.tfvars)(?:\.json)?$/;
937
+ /** One `trivy config` run: the variable files it loads, the directories it skips. */
938
+ interface TrivyRun {
939
+ tfVars: string[];
940
+ skipDirs: string[];
941
+ /** a root's run keeps only findings in that root's closure */
942
+ keep?: string[];
943
+ }
839
944
 
840
945
  /**
841
- * `trivy config` arguments for `cwd`. trivy evaluates variables from their
842
- * defaults unless told otherwise, so a value set in `terraform.tfvars` — an open
843
- * CIDR, a public flag — is invisible to it. With ONE root module its auto-loaded
844
- * variable files are passed, exactly as `terraform plan` would load them. With
845
- * several, trivy would apply one root's values to every root, so none are.
946
+ * The trivy runs that cover `layout`. trivy evaluates variables from their
947
+ * defaults unless told otherwise, so a value set in a `.tfvars` file is
948
+ * invisible to a plain run, and a file passed to a whole-tree run applies to
949
+ * every root in it. So the whole tree is scanned once without variable files,
950
+ * then each root that has some once per file set, with every other root
951
+ * skipped (a root's own directory alone would lose its `../modules`): its
952
+ * auto-loaded files, then those plus each `-var-file` file in turn, as each
953
+ * `terraform plan` would load them. A tree with a single root loads its
954
+ * auto-loaded files in the whole-tree run.
846
955
  */
847
- export function trivyArgs(cwd: string): string[] {
848
- const args = ["config", "--format", "json", "--quiet"];
849
- const roots = resolveRoots(cwd);
850
- const root = roots.length === 1 ? roots[0] : undefined;
851
- if (root) {
852
- let names: string[] = [];
853
- try {
854
- names = readdirSync(root.absDir).filter((n) => AUTO_TFVARS.test(n));
855
- } catch {
856
- // an unreadable root just has no variable files
857
- }
858
- // terraform.tfvars(.json) first, then *.auto.tfvars in lexical order
859
- const ordered = [
860
- ...names.filter((n) => n.startsWith("terraform.tfvars")).sort(),
861
- ...names.filter((n) => !n.startsWith("terraform.tfvars")).sort(),
956
+ export function trivyRuns(layout: TreeLayout, scope: readonly string[] | undefined): TrivyRun[] {
957
+ const inScope = (root: RootLayout) => !scope || scope.includes(root.dir);
958
+ const single = layout.roots.length === 1 ? layout.roots[0] : undefined;
959
+ const runs: TrivyRun[] = [
960
+ {
961
+ tfVars: single && inScope(single) ? single.autoVarFiles : [],
962
+ skipDirs: scope ? skipDirsFor(layout.tfDirs, scope) : [],
963
+ },
964
+ ];
965
+ for (const root of layout.roots.filter(inScope)) {
966
+ const sets = [
967
+ ...(root === single || root.autoVarFiles.length === 0 ? [] : [root.autoVarFiles]),
968
+ ...root.varFiles.map((f) => [...root.autoVarFiles, f]),
862
969
  ];
863
- for (const n of ordered) args.push("--tf-vars", posix.join(root.relDir || ".", n));
970
+ for (const tfVars of sets) {
971
+ runs.push({ tfVars, skipDirs: skipDirsFor(layout.tfDirs, root.closure), keep: root.closure });
972
+ }
864
973
  }
974
+ return runs;
975
+ }
976
+
977
+ /** `trivy config` arguments for one run over the scan directory. */
978
+ export function trivyArgs(run: TrivyRun): string[] {
979
+ // the memory cache: concurrent runs over two trees would otherwise contend
980
+ // for the lock on trivy's on-disk scan cache, which a config scan never reuses
981
+ const args = ["config", "--format", "json", "--quiet", "--cache-backend", "memory"];
982
+ for (const f of run.tfVars) args.push("--tf-vars", f);
983
+ for (const d of run.skipDirs) args.push("--skip-dirs", d);
865
984
  args.push(".");
866
985
  return args;
867
986
  }
868
987
 
988
+ /**
989
+ * Add each variable-file run's findings to the plain run's: only those in the
990
+ * run's root closure, and only those the plain run lacks, each noting the
991
+ * variable files that produce it.
992
+ */
993
+ async function mergeVariableRuns<R extends { tfVars: string[]; keep?: string[] }>(
994
+ plain: Concern[],
995
+ runs: readonly R[],
996
+ scan: (run: R) => Promise<Concern[]>,
997
+ ): Promise<Concern[]> {
998
+ const out = [...plain];
999
+ const seen = new Set(plain.map((c) => c.id));
1000
+ for (const run of runs) {
1001
+ for (const c of await scan(run)) {
1002
+ const dir = posix.dirname(c.location.file);
1003
+ if (seen.has(c.id) || !run.keep?.some((k) => (k || ".") === dir)) continue;
1004
+ seen.add(c.id);
1005
+ out.push({ ...c, evidence: `${c.evidence} (with the values in ${run.tfVars.join(", ")})` });
1006
+ }
1007
+ }
1008
+ return out;
1009
+ }
1010
+
869
1011
  /**
870
1012
  * Parse `trivy config --format json` output into concerns. Trivy nests
871
1013
  * misconfigurations under `Results[].Misconfigurations[]`, keyed to the result's
@@ -879,6 +1021,7 @@ export function trivyArgs(cwd: string): string[] {
879
1021
  export function parseTrivyOutput(stdout: string, cwd = ""): Concern[] {
880
1022
  const parsed = parseScannerJson(TrivyOutput, stdout, "trivy");
881
1023
  const concerns: Concern[] = [];
1024
+ const inModules: ModuleFinding[] = [];
882
1025
  for (const result of parsed.Results ?? []) {
883
1026
  const file = toRepoRelative(result.Target, cwd);
884
1027
  const language = trivyTypeToLanguage(result.Type);
@@ -886,23 +1029,52 @@ export function parseTrivyOutput(stdout: string, cwd = ""): Concern[] {
886
1029
  if (m.Status === "PASS") continue;
887
1030
  const rule = m.AVDID || m.ID || "issue";
888
1031
  const start = m.CauseMetadata?.StartLine;
889
- concerns.push(
890
- buildConcern({
891
- source: "trivy",
892
- rule,
893
- file,
894
- line: typeof start === "number" && start > 0 ? start : null,
895
- severity: lowerSeverity(m.Severity),
896
- category: "security",
897
- evidence: m.Message || m.Description || m.Title || rule,
898
- code_snippet: trivyCodeSnippet(m.CauseMetadata?.Code?.Lines),
899
- remediation_hint: m.Resolution || m.References?.[0] || null,
900
- language,
901
- }),
902
- );
1032
+ const concern = buildConcern({
1033
+ source: "trivy",
1034
+ rule,
1035
+ file,
1036
+ line: typeof start === "number" && start > 0 ? start : null,
1037
+ severity: lowerSeverity(m.Severity),
1038
+ category: "security",
1039
+ evidence: m.Message || m.Description || m.Title || rule,
1040
+ code_snippet: trivyCodeSnippet(m.CauseMetadata?.Code?.Lines),
1041
+ remediation_hint: m.Resolution || m.References?.[0] || null,
1042
+ language,
1043
+ });
1044
+ const call = isFetchedModulePath(file) ? trivyModuleCall(m.CauseMetadata, cwd) : null;
1045
+ if (call) inModules.push({ concern, ...call });
1046
+ else concerns.push(concern);
903
1047
  }
904
1048
  }
905
- return concerns;
1049
+ return [...concerns, ...relocateToCalls(inModules)];
1050
+ }
1051
+
1052
+ /**
1053
+ * The repository call a trivy finding inside a fetched module traces back to.
1054
+ * Trivy lists the finding's occurrences from the resource outwards — the
1055
+ * resource in the module, any module call inside it, then the call in the
1056
+ * repository — so the first occurrence in repository code is the call.
1057
+ */
1058
+ function trivyModuleCall(
1059
+ cause: TrivyCause | undefined,
1060
+ cwd: string,
1061
+ ): Omit<ModuleFinding, "concern"> | null {
1062
+ const occurrences = cause?.Occurrences ?? [];
1063
+ const at = occurrences.findIndex(
1064
+ (o) => o.Filename && !isFetchedModulePath(toRepoRelative(o.Filename, cwd)),
1065
+ );
1066
+ const site = occurrences[at];
1067
+ if (!site?.Filename) return null;
1068
+ const inner = occurrences
1069
+ .slice(0, at)
1070
+ .map((o) => o.Resource)
1071
+ .filter((r): r is string => !!r)
1072
+ .reverse();
1073
+ const address = [cause?.Resource, ...inner].filter(Boolean).join(".");
1074
+ return {
1075
+ call: { file: toRepoRelative(site.Filename, cwd), line: site.Location?.StartLine ?? null },
1076
+ address: address || site.Resource || "the module",
1077
+ };
906
1078
  }
907
1079
 
908
1080
  const TrivyCodeLine = type({
@@ -924,11 +1096,20 @@ const TrivyMisconfiguration = type({
924
1096
  "References?": "string[]",
925
1097
  "Status?": "string",
926
1098
  "CauseMetadata?": {
1099
+ "Resource?": "string",
927
1100
  "StartLine?": "number",
928
1101
  "EndLine?": "number",
929
1102
  "Code?": { "Lines?": TrivyCodeLine.array().or("null") },
1103
+ "Occurrences?": type({
1104
+ "Resource?": "string",
1105
+ "Filename?": "string",
1106
+ "Location?": { "StartLine?": "number" },
1107
+ })
1108
+ .array()
1109
+ .or("null"),
930
1110
  },
931
1111
  });
1112
+ type TrivyCause = NonNullable<(typeof TrivyMisconfiguration.infer)["CauseMetadata"]>;
932
1113
 
933
1114
  // Max source lines (and chars) we keep for a finding's snippet — enough to show
934
1115
  // the offending block, bounded so a pathological scan can't bloat the report.
@@ -1003,7 +1184,7 @@ const TrivyOutput = type({ "Results?": TrivyResult.array().or("null") });
1003
1184
 
1004
1185
  // --- checkov --------------------------------------------------------------
1005
1186
 
1006
- function checkovArgs(downloadExternalModules: boolean, frameworks: string[]): string[] {
1187
+ function checkovArgs(downloadDir: string | undefined, frameworks: string[]): string[] {
1007
1188
  // `--framework` pins checkov to EXACTLY the languages this run scopes to
1008
1189
  // (derived from `iac_languages` ∩ what the tree contains — see
1009
1190
  // checkovFrameworksFor). The default stays Terraform-only so the fix loop's
@@ -1020,13 +1201,47 @@ function checkovArgs(downloadExternalModules: boolean, frameworks: string[]): st
1020
1201
  // naive re-scan with it off stops looking inside the adopted module, so a pile
1021
1202
  // of findings would vanish without being fixed — a false ✗→✓ ("finding-
1022
1203
  // laundering"). Off by default keeps the normal scan fast and offline.
1204
+ //
1205
+ // The download goes to `downloadDir`, outside the tree. Left at checkov's
1206
+ // default it lands in `.external_modules/` in the checkout: the whole git
1207
+ // repository of every module, examples and all, which every later scan then
1208
+ // reads as the repository's own code, and which dirties the tree a push
1209
+ // refuses.
1023
1210
  const args = ["-d", ".", "--framework", ...frameworks, "-o", "json", "--quiet"];
1024
- if (downloadExternalModules) args.push("--download-external-modules", "true");
1211
+ if (downloadDir) {
1212
+ args.push("--download-external-modules", "true");
1213
+ args.push("--external-modules-download-path", downloadDir);
1214
+ }
1025
1215
  return args;
1026
1216
  }
1027
1217
 
1218
+ // not RegExp.escape: these patterns are handed to checkov's Python `re` as
1219
+ // --skip-path values, where JS's `\xNN`-escaped leading character is noise
1220
+ const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1221
+
1222
+ /**
1223
+ * checkov `--skip-path` values for the directories a scoped scan leaves out.
1224
+ * checkov matches each as a regular expression against a path whose form
1225
+ * depends on how it walked there, so a pattern is anchored at a path
1226
+ * separator and the end; one that would also match a directory the scan
1227
+ * keeps (`envs/prod` and `nested/envs/prod`) is not passed — the scope filter
1228
+ * still drops its findings, the scan just reads it.
1229
+ */
1230
+ export function checkovSkipPaths(tfDirs: readonly string[], skip: readonly string[]): string[] {
1231
+ const read = tfDirs.filter((d) => !skip.some((s) => isWithin(d, s)));
1232
+ return skip
1233
+ .map((d) => ({ d, re: new RegExp(`(^|/)${escapeRegExp(d)}$`) }))
1234
+ .filter(({ re }) => !read.some((k) => re.test(k)))
1235
+ .flatMap(({ d }) => ["--skip-path", `(^|/)${escapeRegExp(d)}$`]);
1236
+ }
1237
+
1028
1238
  /** shape a checkov result into an outcome. */
1029
- function checkovOutcome(r: RunResult, cwd: string, egress: unknown): ScannerOutcome {
1239
+ function checkovOutcome(
1240
+ r: RunResult,
1241
+ cwd: string,
1242
+ egress: unknown,
1243
+ downloadDir: string | undefined,
1244
+ ): ScannerOutcome {
1030
1245
  if (r.missing) return skipped("checkov", "checkov not installed");
1031
1246
  const crashed = noReport("checkov", r);
1032
1247
  if (crashed) return crashed;
@@ -1034,7 +1249,7 @@ function checkovOutcome(r: RunResult, cwd: string, egress: unknown): ScannerOutc
1034
1249
  return {
1035
1250
  source: "checkov",
1036
1251
  ran: true,
1037
- concerns: parseCheckovOutput(r.stdout, cwd),
1252
+ concerns: parseCheckovOutput(r.stdout, cwd, downloadDir),
1038
1253
  ...(egress ? { egress_isolation: egressIsolationOf("checkov") } : {}),
1039
1254
  };
1040
1255
  } catch (error) {
@@ -1042,8 +1257,10 @@ function checkovOutcome(r: RunResult, cwd: string, egress: unknown): ScannerOutc
1042
1257
  }
1043
1258
  }
1044
1259
 
1045
- async function scanCheckovAsync(
1260
+ async function scanCheckov(
1046
1261
  cwd: string,
1262
+ layout: TreeLayout,
1263
+ scope: readonly string[] | undefined,
1047
1264
  downloadExternalModules = false,
1048
1265
  frameworks: string[] = ["terraform", "terraform_json"],
1049
1266
  network?: ToolsNetworkDirective,
@@ -1054,26 +1271,71 @@ async function scanCheckovAsync(
1054
1271
  // the exact scanner builds that produced its findings.
1055
1272
  const egress = egressEnvFor("checkov", network);
1056
1273
  assertToolchainIntegrity("checkov", cwd, probeUnder("checkov", egress));
1057
- // checkov scans offline unless it is fetching external modules.
1058
- const scan = scannerSpawn(
1059
- "checkov",
1060
- checkovArgs(downloadExternalModules, frameworks),
1061
- egress,
1062
- downloadExternalModules || operatorHostsAdmitted(network),
1063
- );
1064
- // Python's UTF-8 mode: on a Windows host checkov otherwise decodes source
1065
- // files as cp1252 and dies on the first non-ASCII byte.
1066
- const r = await runAsync(scan.cmd, scan.args, cwd, { ...egress, PYTHONUTF8: "1" });
1067
- return checkovOutcome(r, cwd, egress);
1274
+ const downloadDir = downloadExternalModules
1275
+ ? mkdtempSync(join(tmpdir(), "iw-checkov-modules-"))
1276
+ : undefined;
1277
+ try {
1278
+ return await checkovRuns(cwd, layout, scope, downloadDir, frameworks, egress, network);
1279
+ } finally {
1280
+ if (downloadDir) rmSync(downloadDir, { recursive: true, force: true });
1281
+ }
1282
+ }
1283
+
1284
+ /** The plain checkov run and one per variable file, sharing one module download. */
1285
+ async function checkovRuns(
1286
+ cwd: string,
1287
+ layout: TreeLayout,
1288
+ scope: readonly string[] | undefined,
1289
+ downloadDir: string | undefined,
1290
+ frameworks: string[],
1291
+ egress: Record<string, string> | null,
1292
+ network: ToolsNetworkDirective | undefined,
1293
+ ): Promise<ScannerOutcome> {
1294
+ const runCheckov = async (args: string[], dir: string) => {
1295
+ // checkov scans offline unless it is fetching external modules.
1296
+ const scan = scannerSpawn(
1297
+ "checkov",
1298
+ args,
1299
+ egress,
1300
+ downloadDir !== undefined || operatorHostsAdmitted(network),
1301
+ );
1302
+ // Python's UTF-8 mode: on a Windows host checkov otherwise decodes source
1303
+ // files as cp1252 and dies on the first non-ASCII byte.
1304
+ const r = await runAsync(scan.cmd, scan.args, cwd, { ...egress, PYTHONUTF8: "1" });
1305
+ return checkovOutcome(r, join(cwd, dir), egress, downloadDir);
1306
+ };
1307
+ const skip = scope ? checkovSkipPaths(layout.tfDirs, skipDirsFor(layout.tfDirs, scope)) : [];
1308
+ const outcome = await runCheckov([...checkovArgs(downloadDir, frameworks), ...skip], "");
1309
+ if (!outcome.ran) return outcome;
1310
+ // checkov loads each directory's terraform.tfvars and *.auto.tfvars itself.
1311
+ // A `-var-file` file applies to every directory it scans, so each runs over
1312
+ // its own root — checkov follows `../modules` from there, and reports their
1313
+ // files relative to it.
1314
+ const runs = layout.roots
1315
+ .filter((root) => !scope || scope.includes(root.dir))
1316
+ .flatMap((root) => root.varFiles.map((f) => ({ root, tfVars: [f], keep: root.closure })));
1317
+ const merged = await mergeVariableRuns(outcome.concerns, runs, async ({ root, ...run }) => {
1318
+ const args = checkovArgs(downloadDir, frameworks);
1319
+ args[1] = root.dir || ".";
1320
+ const r = await runCheckov([...args, "--var-file", join(cwd, run.tfVars[0] ?? "")], root.dir);
1321
+ if (!r.ran) {
1322
+ log.warning(`» checkov with ${run.tfVars.join(", ")} did not run — ${r.skipped_reason}`);
1323
+ return [];
1324
+ }
1325
+ return r.concerns.map((c) => rebaseConcern(c, root.dir));
1326
+ });
1327
+ return { ...outcome, concerns: merged };
1068
1328
  }
1069
1329
 
1070
1330
  /** parse `checkov -o json` output (object for one framework, array for several).
1071
1331
  * Each block's `check_type` names the framework that produced it (terraform /
1072
1332
  * cloudformation / kubernetes / …) — carried onto every concern as its language. */
1073
- export function parseCheckovOutput(stdout: string, cwd = ""): Concern[] {
1333
+ export function parseCheckovOutput(stdout: string, cwd = "", downloadDir?: string): Concern[] {
1334
+ const fetched = downloadDir ? { cwd, dir: downloadDir } : undefined;
1074
1335
  const parsed = parseScannerJson(CheckovOutputs, stdout, "checkov");
1075
1336
  const blocks = Array.isArray(parsed) ? parsed : [parsed];
1076
1337
  const concerns: Concern[] = [];
1338
+ const inModules: ModuleFinding[] = [];
1077
1339
  for (const block of blocks) {
1078
1340
  const language = checkovTypeToLanguage(block.check_type);
1079
1341
  for (const check of block.results?.failed_checks ?? []) {
@@ -1082,23 +1344,56 @@ export function parseCheckovOutput(stdout: string, cwd = ""): Concern[] {
1082
1344
  // stable and a reviewer-loaded checkov concern re-verifies ✗→✓.
1083
1345
  const startLine = check.file_line_range?.[0];
1084
1346
  const rule = check.check_id ?? "issue";
1085
- concerns.push(
1086
- buildConcern({
1087
- source: "checkov",
1088
- rule,
1089
- file: toRepoRelative(check.file_path ?? undefined, cwd),
1090
- line: typeof startLine === "number" && startLine > 0 ? startLine : null,
1091
- severity: lowerSeverity(check.severity ?? undefined),
1092
- category: "security",
1093
- evidence: check.check_name ?? rule,
1094
- code_snippet: checkovCodeSnippet(check.code_block),
1095
- remediation_hint: check.guideline ?? null,
1096
- language,
1097
- }),
1098
- );
1347
+ const file = toRepoRelative(check.file_path ?? undefined, cwd);
1348
+ const concern = buildConcern({
1349
+ source: "checkov",
1350
+ rule,
1351
+ file,
1352
+ line: typeof startLine === "number" && startLine > 0 ? startLine : null,
1353
+ // open-source checkov reports a severity only with a Prisma Cloud key;
1354
+ // without one, the engine's own rating of the check (checkovSeverities.ts)
1355
+ severity: check.severity
1356
+ ? lowerSeverity(check.severity)
1357
+ : (CHECKOV_SEVERITIES[rule] ?? "medium"),
1358
+ category: "security",
1359
+ evidence: check.check_name ?? rule,
1360
+ code_snippet: checkovCodeSnippet(check.code_block),
1361
+ remediation_hint: check.guideline ?? null,
1362
+ language,
1363
+ });
1364
+ const call = isFetchedModulePath(file, fetched)
1365
+ ? checkovModuleCall(check, cwd, fetched)
1366
+ : null;
1367
+ if (call) inModules.push({ concern, ...call });
1368
+ else concerns.push(concern);
1099
1369
  }
1100
1370
  }
1101
- return concerns;
1371
+ return [...concerns, ...relocateToCalls(inModules)];
1372
+ }
1373
+
1374
+ /**
1375
+ * The repository call a checkov finding inside a fetched module traces back to.
1376
+ * `caller_file_path` names it when the finding depends on the call's inputs;
1377
+ * otherwise the `source_module_` breadcrumb names the file, and the call is
1378
+ * found in it by the module's name.
1379
+ */
1380
+ function checkovModuleCall(
1381
+ check: CheckovFailedCheck,
1382
+ cwd: string,
1383
+ fetched: FetchDir | undefined,
1384
+ ): Omit<ModuleFinding, "concern"> | null {
1385
+ const address = check.resource ?? "";
1386
+ const caller = check.caller_file_path ? toRepoRelative(check.caller_file_path, cwd) : null;
1387
+ if (caller && !isFetchedModulePath(caller, fetched)) {
1388
+ const line = check.caller_file_line_range?.[0];
1389
+ return { call: { file: caller, line: line && line > 0 ? line : null }, address };
1390
+ }
1391
+ const name = topModuleName(address);
1392
+ const crumb = check.breadcrumbs?.source_module_?.find((b) => b.path)?.path;
1393
+ if (!name || !crumb) return null;
1394
+ const file = toRepoRelative(crumb, cwd);
1395
+ if (isFetchedModulePath(file, fetched)) return null;
1396
+ return { call: { file, line: moduleCallLine(cwd, file, name) }, address };
1102
1397
  }
1103
1398
 
1104
1399
  /** The offending source from a checkov finding's `code_block` — an array of
@@ -1113,6 +1408,12 @@ function checkovCodeSnippet(block: [number, string][] | null | undefined): strin
1113
1408
  // parse — so every optional field here also admits null.
1114
1409
  const CheckovFailedCheck = type({
1115
1410
  "check_id?": "string | null",
1411
+ "resource?": "string | null",
1412
+ "caller_file_path?": "string | null",
1413
+ "caller_file_line_range?": "number[] | null",
1414
+ "breadcrumbs?": type({
1415
+ "source_module_?": type({ "path?": "string | null" }).array().or("null"),
1416
+ }).or("null"),
1116
1417
  "check_name?": "string | null",
1117
1418
  "severity?": "string | null",
1118
1419
  "file_path?": "string | null",
@@ -1120,6 +1421,7 @@ const CheckovFailedCheck = type({
1120
1421
  "code_block?": type(["number", "string"]).array().or("null"),
1121
1422
  "guideline?": "string | null",
1122
1423
  });
1424
+ type CheckovFailedCheck = typeof CheckovFailedCheck.infer;
1123
1425
  const CheckovOutput = type({
1124
1426
  "check_type?": "string",
1125
1427
  "results?": { "failed_checks?": CheckovFailedCheck.array() },
@@ -1196,6 +1498,12 @@ export interface RunScannersOptions {
1196
1498
  * `"partial"` accordingly. Absent ⇒ open egress (today's behaviour) — deny is
1197
1499
  * opt-in. */
1198
1500
  toolsNetwork?: ToolsNetworkDirective | undefined;
1501
+ /** the directories a scoped scan covers (`scopeFor`), relative to the scan
1502
+ * directory; a finding elsewhere is dropped and roots outside are not
1503
+ * initialised. Undefined scans the whole tree. */
1504
+ scope?: string[] | undefined;
1505
+ /** `-var-file` files whose root the `var_files` setting names. */
1506
+ varFiles?: VarFileMapping[] | undefined;
1199
1507
  }
1200
1508
 
1201
1509
  /** run every scanner once over `cwd`. shared by `terraform_scan` and the
@@ -1206,8 +1514,16 @@ export async function runScanners(
1206
1514
  cwd: string,
1207
1515
  opts: RunScannersOptions = {},
1208
1516
  ): Promise<ScannerOutcome[]> {
1209
- const { selection, terraformEnv, downloadExternalModules, checkovFrameworks, toolsNetwork } =
1210
- opts;
1517
+ const {
1518
+ selection,
1519
+ terraformEnv,
1520
+ downloadExternalModules,
1521
+ checkovFrameworks,
1522
+ toolsNetwork,
1523
+ scope,
1524
+ } = opts;
1525
+ const layout = treeLayout(cwd, opts.varFiles);
1526
+ reportUnmatchedVarFiles(layout);
1211
1527
  const gate = async (
1212
1528
  source: Concern["source"],
1213
1529
  runner: () => Promise<ScannerOutcome>,
@@ -1228,29 +1544,23 @@ export async function runScanners(
1228
1544
  const validateOn = !selection || !validateToolId || selection.enabled(validateToolId);
1229
1545
  // one root walk, shared by init and validate — the sequential version resolved
1230
1546
  // them once inside scanValidate, and this must not become two walks.
1231
- const roots = validateOn ? resolveRoots(cwd) : [];
1547
+ const roots = validateOn
1548
+ ? resolveRoots(cwd).filter((r) => !scope || scope.includes(r.relDir))
1549
+ : [];
1232
1550
  if (validateOn) await primeTerraformInit(roots, terraformEnv);
1233
1551
 
1234
1552
  // PHASE 2 — read-only scanners, concurrently. This is the win: the wall clock
1235
- // becomes the slowest scanner rather than the sum of all of them.
1236
- //
1237
- // checkov joins this phase ONLY when it is not downloading external modules.
1238
- // With `--download-external-modules true` it WRITES `.external_modules/` into
1239
- // the tree, which tflint and trivy are reading — today it runs last, so they
1240
- // never see it, and that must not change on a timing race.
1241
- const checkovIsWriter = downloadExternalModules === true;
1553
+ // becomes the slowest scanner rather than the sum of all of them. checkov is
1554
+ // read-only here even when it downloads external modules: they go to a
1555
+ // directory outside the tree (see checkovArgs).
1242
1556
  const parallel: Promise<ScannerOutcome>[] = [
1243
- gate("terraform-fmt", () => scanFmtAsync(cwd)),
1244
- gate("terraform-validate", () => scanValidateAsync(roots)),
1245
- gate("tflint", () => scanTflintAsync(cwd, toolsNetwork)),
1246
- gate("trivy", () => scanTrivyAsync(cwd, toolsNetwork)),
1247
- ...(checkovIsWriter
1248
- ? []
1249
- : [
1250
- gate("checkov", () =>
1251
- scanCheckovAsync(cwd, downloadExternalModules, checkovFrameworks, toolsNetwork),
1252
- ),
1253
- ]),
1557
+ gate("terraform-fmt", () => scanFmt(cwd)),
1558
+ gate("terraform-validate", () => validateRoots(roots)),
1559
+ gate("tflint", () => scanTflint(cwd, toolsNetwork)),
1560
+ gate("trivy", () => scanTrivy(cwd, layout, scope, toolsNetwork)),
1561
+ gate("checkov", () =>
1562
+ scanCheckov(cwd, layout, scope, downloadExternalModules, checkovFrameworks, toolsNetwork),
1563
+ ),
1254
1564
  ];
1255
1565
  // Not `Promise.all`: an integrity gate REFUSING a compromised scanner throws,
1256
1566
  // and with Promise.all the other rejections would surface as unhandled while
@@ -1261,22 +1571,32 @@ export async function runScanners(
1261
1571
  if (rejection?.status === "rejected") throw rejection.reason;
1262
1572
  const upstream = settled.map((s) => (s as PromiseFulfilledResult<ScannerOutcome>).value);
1263
1573
 
1264
- // PHASE 3 — the tree-writing checkov, after every reader has finished.
1265
- if (checkovIsWriter) {
1266
- upstream.push(
1267
- await gate("checkov", () =>
1268
- scanCheckovAsync(cwd, downloadExternalModules, checkovFrameworks, toolsNetwork),
1269
- ),
1270
- );
1271
- }
1272
-
1273
1574
  // The native pack is never gated: it is our own code, carries no third-party
1274
1575
  // licence, and shells out to nothing (`scannerToolId` returns null for it).
1275
1576
  // It reads the provider schema that PHASE 1's init produced.
1276
- return [
1577
+ const outcomes = [
1277
1578
  ...upstream,
1278
1579
  scanNative(cwd, { repeatedLiteralThreshold: opts.repeatedLiteralThreshold }),
1279
1580
  ];
1581
+ if (!scope) return outcomes;
1582
+ const kept = new Set(scope);
1583
+ const inScope = (c: Concern) => {
1584
+ const dir = posix.dirname(c.location.file);
1585
+ return kept.has(dir === "." ? "" : dir);
1586
+ };
1587
+ return outcomes.map((o) => ({ ...o, concerns: o.concerns.filter(inScope) }));
1588
+ }
1589
+
1590
+ const reportedVarFiles = new Set<string>();
1591
+
1592
+ /** Say once per run which variable files no scan loads, and why. */
1593
+ function reportUnmatchedVarFiles(layout: TreeLayout): void {
1594
+ for (const { file, reason } of layout.unmatched) {
1595
+ const line = `» ${file} is not loaded by the scan: ${reason}`;
1596
+ if (reportedVarFiles.has(line)) continue;
1597
+ reportedVarFiles.add(line);
1598
+ log.info(line);
1599
+ }
1280
1600
  }
1281
1601
 
1282
1602
  export interface RemediationVerdict {