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.
- 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 +31903 -18736
- package/dist/external.d.ts +2 -1
- package/dist/index.js +31987 -18749
- package/dist/internal.js +132 -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 +34 -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 +6 -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
|
@@ -1,11 +1,27 @@
|
|
|
1
|
-
import { existsSync,
|
|
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 {
|
|
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 {
|
|
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,
|
|
43
|
-
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
*
|
|
97
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
/**
|
|
838
|
-
|
|
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
|
-
*
|
|
842
|
-
* defaults unless told otherwise, so a value set in
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
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
|
|
848
|
-
const
|
|
849
|
-
const
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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(
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
egress,
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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 {
|
|
1210
|
-
|
|
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
|
|
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
|
-
//
|
|
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", () =>
|
|
1244
|
-
gate("terraform-validate", () =>
|
|
1245
|
-
gate("tflint", () =>
|
|
1246
|
-
gate("trivy", () =>
|
|
1247
|
-
|
|
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
|
-
|
|
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 {
|