terramend 0.2.0
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/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- package/src/utils/workflow.ts +25 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { walkTfFiles } from "#app/mcp/modules";
|
|
4
|
+
import { loadProvidersSchema, unknownArgsForResource } from "#app/mcp/providerSchema";
|
|
5
|
+
import {
|
|
6
|
+
type Concern,
|
|
7
|
+
concernId,
|
|
8
|
+
dedupe,
|
|
9
|
+
lowerSeverity,
|
|
10
|
+
type ResolvedRoot,
|
|
11
|
+
rebaseConcern,
|
|
12
|
+
resolveBaseRef,
|
|
13
|
+
resolveRoots,
|
|
14
|
+
run,
|
|
15
|
+
type ScannerOutcome,
|
|
16
|
+
type Severity,
|
|
17
|
+
skipped,
|
|
18
|
+
toRepoRelative,
|
|
19
|
+
} from "#app/mcp/terraform/types";
|
|
20
|
+
import { log } from "#app/utils/cli";
|
|
21
|
+
|
|
22
|
+
// dirs already `terraform init`-ed this process, so repeated scans don't re-init.
|
|
23
|
+
const initedDirs = new Set<string>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run `terraform init -backend=false` once per dir so `terraform validate` has
|
|
27
|
+
* provider schemas to check against (Bug 3 / gap B). Without init, validate only
|
|
28
|
+
* emits "missing required provider" — which VALIDATE_NOISE drops — so it was
|
|
29
|
+
* effectively inert. `-backend=false` avoids needing real backend credentials;
|
|
30
|
+
* `-input=false` keeps it non-interactive. Network-dependent and best-effort: if
|
|
31
|
+
* it fails (offline, private module, etc.) validate still runs, just shallow.
|
|
32
|
+
*/
|
|
33
|
+
function ensureTerraformInit(cwd: string): void {
|
|
34
|
+
if (initedDirs.has(cwd)) return;
|
|
35
|
+
const r = run("terraform", ["init", "-backend=false", "-input=false", "-no-color"], cwd);
|
|
36
|
+
// mark done even on non-zero: a failed init won't succeed on retry within the
|
|
37
|
+
// same run, and we don't want to re-run it for every scanner call.
|
|
38
|
+
initedDirs.add(cwd);
|
|
39
|
+
if (r.status !== 0 && !r.missing) {
|
|
40
|
+
log.info(`» terraform init (for validate) did not complete cleanly — validate may be shallow`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- terraform fmt -------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export function scanFmt(cwd: string): ScannerOutcome {
|
|
47
|
+
const r = run("terraform", ["fmt", "-check", "-recursive", "-list=true"], cwd);
|
|
48
|
+
if (r.missing) return skipped("terraform-fmt", "terraform not installed");
|
|
49
|
+
// exit 0 = all formatted; exit 3 = files need formatting (lists them on stdout);
|
|
50
|
+
// other non-zero = real error (e.g. parse failure) — surface nothing, validate covers it.
|
|
51
|
+
if (r.status === 0) return { source: "terraform-fmt", ran: true, concerns: [] };
|
|
52
|
+
return { source: "terraform-fmt", ran: true, concerns: parseFmtOutput(r.stdout, cwd) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `terraform fmt -check -list=true` prints one unformatted file path per line. */
|
|
56
|
+
export function parseFmtOutput(stdout: string, cwd = ""): Concern[] {
|
|
57
|
+
const files = stdout
|
|
58
|
+
.split("\n")
|
|
59
|
+
.map((l) => l.trim())
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
return files.map<Concern>((raw) => {
|
|
62
|
+
const file = toRepoRelative(raw, cwd);
|
|
63
|
+
return {
|
|
64
|
+
id: concernId("terraform-fmt", "unformatted", file, null),
|
|
65
|
+
source: "terraform-fmt",
|
|
66
|
+
rule_id: "terraform-fmt:unformatted",
|
|
67
|
+
severity: "low",
|
|
68
|
+
category: "style",
|
|
69
|
+
evidence: "File does not match `terraform fmt` canonical style.",
|
|
70
|
+
location: { file, line: null },
|
|
71
|
+
remediation_hint: "Run `terraform fmt` to apply canonical formatting.",
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- terraform validate ---------------------------------------------------
|
|
77
|
+
|
|
78
|
+
// diagnostics that are environmental (the dir isn't initialized, or a provider
|
|
79
|
+
// plugin failed to install/launch) rather than a real best-practice issue.
|
|
80
|
+
// dropped so a scan can't emit false positives from toolchain hiccups — e.g.
|
|
81
|
+
// after `terraform init` (Bug 3), a crashed provider plugin surfaces as
|
|
82
|
+
// "Failed to load plugin schemas", which is noise, not a defect in the HCL.
|
|
83
|
+
const VALIDATE_NOISE = [
|
|
84
|
+
"terraform init",
|
|
85
|
+
"missing required provider",
|
|
86
|
+
"module not installed",
|
|
87
|
+
"module is not yet installed",
|
|
88
|
+
"required plugins are not installed",
|
|
89
|
+
"uninitialized",
|
|
90
|
+
"failed to load plugin",
|
|
91
|
+
"plugin did not respond",
|
|
92
|
+
"could not load plugin",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** run `terraform validate` in one root and return concerns re-based onto cwd. */
|
|
96
|
+
function scanValidateRoot(root: ResolvedRoot): ScannerOutcome {
|
|
97
|
+
ensureTerraformInit(root.absDir);
|
|
98
|
+
const r = run("terraform", ["validate", "-json"], root.absDir);
|
|
99
|
+
if (r.missing) return skipped("terraform-validate", "terraform not installed");
|
|
100
|
+
try {
|
|
101
|
+
const concerns = parseValidateOutput(r.stdout, root.absDir).map((c) =>
|
|
102
|
+
rebaseConcern(c, root.relDir),
|
|
103
|
+
);
|
|
104
|
+
return { source: "terraform-validate", ran: true, concerns };
|
|
105
|
+
} catch {
|
|
106
|
+
// terraform ran but emitted output we couldn't parse — a real CLI-level
|
|
107
|
+
// failure (corrupted .terraform, a crash, an ancient terraform without
|
|
108
|
+
// `-json`), NOT a clean tree. Flag it as unvalidated so the tool fails
|
|
109
|
+
// closed rather than reporting this root as passing.
|
|
110
|
+
return {
|
|
111
|
+
...skipped("terraform-validate", "could not parse `terraform validate -json` output"),
|
|
112
|
+
unvalidated: 1,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run `terraform validate` across EVERY root and aggregate. `validate` is the
|
|
119
|
+
* one scanner that's per-root (fmt/tflint/trivy/checkov are recursive over the
|
|
120
|
+
* whole tree), so a multi-root repo only catches subdir-root validate errors
|
|
121
|
+
* when we visit each root.
|
|
122
|
+
*/
|
|
123
|
+
export function scanValidate(cwd: string): ScannerOutcome {
|
|
124
|
+
const roots = resolveRoots(cwd);
|
|
125
|
+
const concerns: Concern[] = [];
|
|
126
|
+
let anyRan = false;
|
|
127
|
+
let sawMissing = false;
|
|
128
|
+
let unvalidated = 0;
|
|
129
|
+
for (const root of roots) {
|
|
130
|
+
const outcome = scanValidateRoot(root);
|
|
131
|
+
unvalidated += outcome.unvalidated ?? 0;
|
|
132
|
+
if (outcome.ran) {
|
|
133
|
+
anyRan = true;
|
|
134
|
+
concerns.push(...outcome.concerns);
|
|
135
|
+
} else if (outcome.skipped_reason?.includes("not installed")) {
|
|
136
|
+
sawMissing = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!anyRan) {
|
|
140
|
+
return sawMissing
|
|
141
|
+
? skipped("terraform-validate", "terraform not installed")
|
|
142
|
+
: {
|
|
143
|
+
...skipped("terraform-validate", "could not parse `terraform validate -json` output"),
|
|
144
|
+
unvalidated,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { source: "terraform-validate", ran: true, concerns: dedupe(concerns), unvalidated };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** parse `terraform validate -json`; keeps real errors, drops uninitialized-dir noise. */
|
|
151
|
+
export function parseValidateOutput(stdout: string, cwd = ""): Concern[] {
|
|
152
|
+
const parsed = JSON.parse(stdout || "{}") as { diagnostics?: ValidateDiagnostic[] };
|
|
153
|
+
const diags = (parsed.diagnostics ?? []).filter((d) => d.severity === "error");
|
|
154
|
+
const concerns: Concern[] = [];
|
|
155
|
+
for (const d of diags) {
|
|
156
|
+
const text = `${d.summary ?? ""} ${d.detail ?? ""}`.toLowerCase();
|
|
157
|
+
if (VALIDATE_NOISE.some((n) => text.includes(n))) continue;
|
|
158
|
+
const file = toRepoRelative(d.range?.filename, cwd);
|
|
159
|
+
const line = d.range?.start?.line ?? null;
|
|
160
|
+
concerns.push({
|
|
161
|
+
id: concernId("terraform-validate", d.summary ?? "error", file, line),
|
|
162
|
+
source: "terraform-validate",
|
|
163
|
+
rule_id: `terraform-validate:${d.summary ?? "error"}`,
|
|
164
|
+
severity: "high",
|
|
165
|
+
category: "correctness",
|
|
166
|
+
evidence: [d.summary, d.detail].filter(Boolean).join(" — "),
|
|
167
|
+
location: { file, line },
|
|
168
|
+
remediation_hint: null,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return concerns;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface ValidateDiagnostic {
|
|
175
|
+
severity?: string;
|
|
176
|
+
summary?: string;
|
|
177
|
+
detail?: string;
|
|
178
|
+
range?: { filename?: string; start?: { line?: number } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- provider-version awareness (§4.15) ------------------------------------
|
|
182
|
+
|
|
183
|
+
export interface ProviderRequirement {
|
|
184
|
+
/** local name, e.g. `aws`. */
|
|
185
|
+
name: string;
|
|
186
|
+
/** registry source, e.g. `hashicorp/aws`, or null (legacy string form). */
|
|
187
|
+
source: string | null;
|
|
188
|
+
/** raw version constraint, e.g. `~> 5.0`, or null when unconstrained. */
|
|
189
|
+
version: string | null;
|
|
190
|
+
/** the pinned MAJOR (the lower-bound major of the constraint) — the number a
|
|
191
|
+
* fix must target, since argument schemas differ across provider majors. */
|
|
192
|
+
major: number | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** the lower-bound major version from a constraint string (`~> 5.0` → 5,
|
|
196
|
+
* `>= 3.1, < 4.0` → 3, `5` → 5). null when no number is present. */
|
|
197
|
+
function majorOf(version: string | null): number | null {
|
|
198
|
+
if (!version) return null;
|
|
199
|
+
const m = version.match(/(\d+)\s*\.\s*\d+/) ?? version.match(/(\d+)/);
|
|
200
|
+
return m ? Number(m[1]) : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Parse every `required_providers { … }` block in some HCL text into the pinned
|
|
205
|
+
* provider requirements. Handles the modern object form
|
|
206
|
+
* (`aws = { source = "hashicorp/aws", version = "~> 5.0" }`) and the legacy
|
|
207
|
+
* string form (`aws = "~> 5.0"`). A repo's "correct" fix depends on the provider
|
|
208
|
+
* MAJOR — argument names and valid blocks differ across AWS/Azure majors — so
|
|
209
|
+
* surfacing the pinned major lets a fix target the right schema instead of
|
|
210
|
+
* breaking `plan`. Brace-matched (not a fragile single regex) so nested objects
|
|
211
|
+
* don't confuse it. First declaration of a name wins (dedup across files).
|
|
212
|
+
*/
|
|
213
|
+
export function parseRequiredProviders(hcl: string): ProviderRequirement[] {
|
|
214
|
+
const out: ProviderRequirement[] = [];
|
|
215
|
+
const seen = new Set<string>();
|
|
216
|
+
let searchFrom = 0;
|
|
217
|
+
for (;;) {
|
|
218
|
+
const idx = hcl.indexOf("required_providers", searchFrom);
|
|
219
|
+
if (idx === -1) break;
|
|
220
|
+
const braceStart = hcl.indexOf("{", idx);
|
|
221
|
+
if (braceStart === -1) break;
|
|
222
|
+
let depth = 0;
|
|
223
|
+
let end = -1;
|
|
224
|
+
for (let i = braceStart; i < hcl.length; i++) {
|
|
225
|
+
if (hcl[i] === "{") depth++;
|
|
226
|
+
else if (hcl[i] === "}") {
|
|
227
|
+
depth--;
|
|
228
|
+
if (depth === 0) {
|
|
229
|
+
end = i;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (end === -1) break;
|
|
235
|
+
const body = hcl.slice(braceStart + 1, end);
|
|
236
|
+
searchFrom = end + 1;
|
|
237
|
+
|
|
238
|
+
// object form: name = { source = "…", version = "…" }
|
|
239
|
+
const objRe = /([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*\{([^}]*)\}/g;
|
|
240
|
+
let m: RegExpExecArray | null;
|
|
241
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
|
|
242
|
+
while ((m = objRe.exec(body)) !== null) {
|
|
243
|
+
const name = m[1]!;
|
|
244
|
+
const inner = m[2]!;
|
|
245
|
+
if (seen.has(name)) continue;
|
|
246
|
+
seen.add(name);
|
|
247
|
+
const source = inner.match(/source\s*=\s*"([^"]+)"/)?.[1] ?? null;
|
|
248
|
+
const version = inner.match(/version\s*=\s*"([^"]+)"/)?.[1] ?? null;
|
|
249
|
+
out.push({ name, source, version, major: majorOf(version) });
|
|
250
|
+
}
|
|
251
|
+
// legacy string form: name = "version" — run on the body with object blocks
|
|
252
|
+
// stripped so an object's inner `source =`/`version =` lines aren't matched.
|
|
253
|
+
const bodyNoObjects = body.replace(/([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*\{[^}]*\}/g, "");
|
|
254
|
+
const strRe = /([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*"([^"]+)"/g;
|
|
255
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
|
|
256
|
+
while ((m = strRe.exec(bodyNoObjects)) !== null) {
|
|
257
|
+
const name = m[1]!;
|
|
258
|
+
if (seen.has(name)) continue;
|
|
259
|
+
seen.add(name);
|
|
260
|
+
out.push({ name, source: null, version: m[2]!, major: majorOf(m[2]!) });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** read the repo's `*.tf` files (recursively — root + subdir roots + nested
|
|
267
|
+
* modules) and parse their pinned provider requirements (best-effort; an
|
|
268
|
+
* unreadable tree yields none). First declaration of a provider wins. */
|
|
269
|
+
export function collectProviderRequirements(cwd: string): ProviderRequirement[] {
|
|
270
|
+
let text = "";
|
|
271
|
+
for (const f of walkTfFiles(cwd)) {
|
|
272
|
+
try {
|
|
273
|
+
text += `${readFileSync(join(cwd, f), "utf8")}\n`;
|
|
274
|
+
} catch {
|
|
275
|
+
/* skip unreadable file */
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return parseRequiredProviders(text);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- §4.15-next: argument-vs-schema validation ------------------------------
|
|
282
|
+
|
|
283
|
+
/** the top-level arguments of a single `resource` block. */
|
|
284
|
+
export interface ResourceArguments {
|
|
285
|
+
resourceType: string;
|
|
286
|
+
/** the resource's local name (`resource "aws_s3_bucket" "<name>"`). */
|
|
287
|
+
name: string;
|
|
288
|
+
/** top-level attribute + nested-block names (meta-arguments excluded). */
|
|
289
|
+
args: string[];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Terraform meta-arguments are valid on EVERY resource and never appear in a
|
|
293
|
+
// provider's schema — exclude them so they're not flagged as unknown. `dynamic`
|
|
294
|
+
// is handled specially (its quoted label is the real block name).
|
|
295
|
+
const RESOURCE_META_ARGUMENTS: ReadonlySet<string> = new Set([
|
|
296
|
+
"count",
|
|
297
|
+
"for_each",
|
|
298
|
+
"provider",
|
|
299
|
+
"depends_on",
|
|
300
|
+
"lifecycle",
|
|
301
|
+
"provisioner",
|
|
302
|
+
"connection",
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Parse every `resource "<type>" "<name>" { … }` block's TOP-LEVEL argument
|
|
307
|
+
* names (attributes assigned with `=` and nested block labels) from some HCL.
|
|
308
|
+
* Conservative by design — it skips `"…"` strings and `#`/`//` line comments so
|
|
309
|
+
* an interpolation's braces or a commented line can't corrupt the brace depth or
|
|
310
|
+
* fabricate an argument, and only reports depth-0 names. A `dynamic "x"` block
|
|
311
|
+
* contributes `x` (the generated block type). Used to cross-check written
|
|
312
|
+
* arguments against the installed provider schema; pure.
|
|
313
|
+
*/
|
|
314
|
+
export function parseResourceArguments(hcl: string): ResourceArguments[] {
|
|
315
|
+
const out: ResourceArguments[] = [];
|
|
316
|
+
const re = /(?:^|\n)\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/g;
|
|
317
|
+
let m: RegExpExecArray | null;
|
|
318
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
|
|
319
|
+
while ((m = re.exec(hcl)) !== null) {
|
|
320
|
+
const resourceType = m[1]!;
|
|
321
|
+
const name = m[2]!;
|
|
322
|
+
const braceStart = hcl.indexOf("{", m.index);
|
|
323
|
+
if (braceStart === -1) break;
|
|
324
|
+
const body = matchBraceBody(hcl, braceStart);
|
|
325
|
+
if (!body) break;
|
|
326
|
+
re.lastIndex = body.end + 1;
|
|
327
|
+
out.push({ resourceType, name, args: topLevelArgNames(body.text) });
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A non-code span in HCL — a `"…"` string, a `#`/`//` line comment, or a
|
|
334
|
+
* `<<EOF` heredoc — that the brace/argument scanners must skip wholesale (an
|
|
335
|
+
* interpolation's `${…}`, a commented `}`, or a heredoc's `key = value` lines
|
|
336
|
+
* would otherwise corrupt brace depth or fabricate arguments). Returned for a
|
|
337
|
+
* span starting at `i`: `end` is the index of its LAST char (caller resumes at
|
|
338
|
+
* `end + 1`), and `endsLine` is true when the span finished at a line boundary
|
|
339
|
+
* (comment / heredoc — the next token begins a fresh statement). null when `i`
|
|
340
|
+
* isn't the start of a non-code span. Single source of truth for both scanners.
|
|
341
|
+
*/
|
|
342
|
+
function skipNonCode(s: string, i: number): { end: number; endsLine: boolean } | null {
|
|
343
|
+
const ch = s[i];
|
|
344
|
+
// double-quoted string (with `\` escapes). Ends mid-line.
|
|
345
|
+
if (ch === '"') {
|
|
346
|
+
let j = i + 1;
|
|
347
|
+
while (j < s.length && s[j] !== '"') {
|
|
348
|
+
if (s[j] === "\\") j++;
|
|
349
|
+
j++;
|
|
350
|
+
}
|
|
351
|
+
return { end: j, endsLine: false };
|
|
352
|
+
}
|
|
353
|
+
// heredoc body (`<<EOF` / `<<-EOF` / `<<~EOF`) — arbitrary text.
|
|
354
|
+
if (ch === "<" && s[i + 1] === "<") {
|
|
355
|
+
const m = /^<<[-~]?([A-Za-z_][A-Za-z0-9_]*)/.exec(s.slice(i, i + 80));
|
|
356
|
+
if (m) {
|
|
357
|
+
const delim = m[1];
|
|
358
|
+
const openNl = s.indexOf("\n", i);
|
|
359
|
+
if (openNl === -1) return { end: s.length - 1, endsLine: true };
|
|
360
|
+
// the closing delimiter sits alone on its own line (optionally indented).
|
|
361
|
+
const closeRe = new RegExp(`\\n[ \\t]*${delim}[ \\t]*(?=\\n|$)`);
|
|
362
|
+
const cm = closeRe.exec(s.slice(openNl));
|
|
363
|
+
const end = cm ? openNl + cm.index + cm[0].length - 1 : s.length - 1;
|
|
364
|
+
return { end, endsLine: true };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// `#` or `//` line comment.
|
|
368
|
+
if (ch === "#" || (ch === "/" && s[i + 1] === "/")) {
|
|
369
|
+
const nl = s.indexOf("\n", i);
|
|
370
|
+
return { end: nl === -1 ? s.length - 1 : nl, endsLine: true };
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** brace-match from an opening `{` at `open`; returns the inner text + end index
|
|
376
|
+
* (the matching `}`), string/comment/heredoc-aware so interpolation braces, a
|
|
377
|
+
* commented `}`, or a heredoc body don't fool it. */
|
|
378
|
+
function matchBraceBody(hcl: string, open: string | number): { text: string; end: number } | null {
|
|
379
|
+
const start = typeof open === "number" ? open : -1;
|
|
380
|
+
if (start < 0) return null;
|
|
381
|
+
let depth = 0;
|
|
382
|
+
for (let i = start; i < hcl.length; i++) {
|
|
383
|
+
const span = skipNonCode(hcl, i);
|
|
384
|
+
if (span) {
|
|
385
|
+
i = span.end;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const ch = hcl[i];
|
|
389
|
+
if (ch === "{") depth++;
|
|
390
|
+
else if (ch === "}") {
|
|
391
|
+
depth--;
|
|
392
|
+
if (depth === 0) return { text: hcl.slice(start + 1, i), end: i };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** extract the depth-0 argument names from a resource block body (string-,
|
|
399
|
+
* comment-, and heredoc-aware), excluding meta-arguments. */
|
|
400
|
+
function topLevelArgNames(body: string): string[] {
|
|
401
|
+
const names = new Set<string>();
|
|
402
|
+
let depth = 0;
|
|
403
|
+
let atStmtStart = true;
|
|
404
|
+
for (let i = 0; i < body.length; i++) {
|
|
405
|
+
const span = skipNonCode(body, i);
|
|
406
|
+
if (span) {
|
|
407
|
+
i = span.end;
|
|
408
|
+
// a comment/heredoc ends a line (next token is a fresh statement); a
|
|
409
|
+
// string ends mid-line (still inside the current statement).
|
|
410
|
+
atStmtStart = span.endsLine;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const ch = body[i]!;
|
|
414
|
+
if (ch === "{") {
|
|
415
|
+
depth++;
|
|
416
|
+
atStmtStart = false;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (ch === "}") {
|
|
420
|
+
depth--;
|
|
421
|
+
atStmtStart = false;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (ch === "\n") {
|
|
425
|
+
atStmtStart = true;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (ch === " " || ch === "\t" || ch === "\r") continue;
|
|
429
|
+
// a non-space char that starts a statement at depth 0 → read an identifier.
|
|
430
|
+
if (depth === 0 && atStmtStart && /[A-Za-z_]/.test(ch)) {
|
|
431
|
+
let j = i;
|
|
432
|
+
while (j < body.length && /[A-Za-z0-9_-]/.test(body[j]!)) j++;
|
|
433
|
+
const ident = body.slice(i, j);
|
|
434
|
+
// skip whitespace after the identifier to classify it.
|
|
435
|
+
let k = j;
|
|
436
|
+
while (k < body.length && (body[k] === " " || body[k] === "\t")) k++;
|
|
437
|
+
const next = body[k];
|
|
438
|
+
if (next === "=" && body[k + 1] !== "=") {
|
|
439
|
+
// attribute assignment: `name = …`
|
|
440
|
+
if (!RESOURCE_META_ARGUMENTS.has(ident)) names.add(ident);
|
|
441
|
+
} else if (next === "{") {
|
|
442
|
+
// nested block: `name { … }`
|
|
443
|
+
if (!RESOURCE_META_ARGUMENTS.has(ident)) names.add(ident);
|
|
444
|
+
} else if (next === '"') {
|
|
445
|
+
// labeled block: `dynamic "x" {` → the generated block is `x`;
|
|
446
|
+
// other labeled blocks (`provisioner "remote-exec"`) are meta.
|
|
447
|
+
if (ident === "dynamic") {
|
|
448
|
+
const labelEnd = body.indexOf('"', k + 1);
|
|
449
|
+
if (labelEnd !== -1) names.add(body.slice(k + 1, labelEnd));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
i = j - 1;
|
|
453
|
+
atStmtStart = false;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
atStmtStart = false;
|
|
457
|
+
}
|
|
458
|
+
return [...names];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export interface UnknownArgument {
|
|
462
|
+
resource_type: string;
|
|
463
|
+
/** the resource's local name. */
|
|
464
|
+
name: string;
|
|
465
|
+
/** repo-relative file the resource is declared in. */
|
|
466
|
+
file: string;
|
|
467
|
+
/** the argument names not present in the installed provider's schema. */
|
|
468
|
+
unknown: string[];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* §4.15-next — cross-check every resource's written arguments against the
|
|
473
|
+
* INSTALLED provider's schema, so an argument that's invalid for the pinned
|
|
474
|
+
* provider major (a "correct" fix for the wrong version) is caught at validate
|
|
475
|
+
* time, not as a later `plan` failure. Degrades green: returns
|
|
476
|
+
* `{ checked: false }` when the schema is unavailable (terraform not installed /
|
|
477
|
+
* dir not init-ed). A resource type absent from the schema is skipped (can't
|
|
478
|
+
* judge), never flagged.
|
|
479
|
+
*/
|
|
480
|
+
export function checkArgumentsAgainstSchema(cwd: string): {
|
|
481
|
+
checked: boolean;
|
|
482
|
+
unknown_arguments: UnknownArgument[];
|
|
483
|
+
} {
|
|
484
|
+
const schema = loadProvidersSchema(cwd);
|
|
485
|
+
if (!schema) return { checked: false, unknown_arguments: [] };
|
|
486
|
+
const out: UnknownArgument[] = [];
|
|
487
|
+
for (const f of walkTfFiles(cwd)) {
|
|
488
|
+
let text: string;
|
|
489
|
+
try {
|
|
490
|
+
text = readFileSync(join(cwd, f), "utf8");
|
|
491
|
+
} catch {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
for (const block of parseResourceArguments(text)) {
|
|
495
|
+
const verdict = unknownArgsForResource(schema, block.resourceType, block.args);
|
|
496
|
+
if (!verdict.unknownResourceType && verdict.unknown.length > 0) {
|
|
497
|
+
out.push({
|
|
498
|
+
resource_type: block.resourceType,
|
|
499
|
+
name: block.name,
|
|
500
|
+
file: f,
|
|
501
|
+
unknown: verdict.unknown,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { checked: true, unknown_arguments: out };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// --- tflint ---------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
function tflintSeverity(s: string | undefined): Severity {
|
|
512
|
+
switch ((s ?? "").toLowerCase()) {
|
|
513
|
+
case "error":
|
|
514
|
+
return "high";
|
|
515
|
+
case "warning":
|
|
516
|
+
return "medium";
|
|
517
|
+
default:
|
|
518
|
+
return "low";
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// dirs we've already attempted `tflint --init` in, so repeated scans don't re-init.
|
|
523
|
+
const tflintInitedDirs = new Set<string>();
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Install tflint's provider ruleset plugins via `tflint --init` when the dir has
|
|
527
|
+
* a `.tflint.hcl` declaring them. Core `tflint --recursive` runs only the
|
|
528
|
+
* built-in rules; the high-value provider rules (deprecated args, invalid
|
|
529
|
+
* instance types, missing-tag policies, etc.) live in the aws/azurerm/google
|
|
530
|
+
* plugins, which must be installed first. Opt-in by design — we only init when
|
|
531
|
+
* the repo ships a `.tflint.hcl`, so we don't force AWS rules onto an Azure/GCP
|
|
532
|
+
* repo. Best-effort and network-dependent: a failed init just leaves tflint
|
|
533
|
+
* running its core rules, exactly as before.
|
|
534
|
+
*/
|
|
535
|
+
function ensureTflintInit(cwd: string): void {
|
|
536
|
+
if (tflintInitedDirs.has(cwd)) return;
|
|
537
|
+
// mark first: a failed init won't succeed on retry within the same run, and
|
|
538
|
+
// we don't want to re-attempt the network fetch on every scanner call.
|
|
539
|
+
tflintInitedDirs.add(cwd);
|
|
540
|
+
if (!existsSync(join(cwd, ".tflint.hcl"))) return;
|
|
541
|
+
const r = run("tflint", ["--init"], cwd);
|
|
542
|
+
if (r.status !== 0 && !r.missing) {
|
|
543
|
+
log.info(
|
|
544
|
+
"» tflint --init did not complete cleanly — provider ruleset plugins may be unavailable",
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function scanTflint(cwd: string): ScannerOutcome {
|
|
550
|
+
ensureTflintInit(cwd);
|
|
551
|
+
const r = run("tflint", ["--format", "json", "--recursive"], cwd);
|
|
552
|
+
if (r.missing) return skipped("tflint", "tflint not installed");
|
|
553
|
+
try {
|
|
554
|
+
return { source: "tflint", ran: true, concerns: parseTflintOutput(r.stdout, cwd) };
|
|
555
|
+
} catch {
|
|
556
|
+
return skipped("tflint", "could not parse tflint json output");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** parse `tflint --format json` output into concerns. */
|
|
561
|
+
export function parseTflintOutput(stdout: string, cwd = ""): Concern[] {
|
|
562
|
+
const parsed = JSON.parse(stdout || "{}") as { issues?: TflintIssue[] };
|
|
563
|
+
return (parsed.issues ?? []).map<Concern>((issue) => {
|
|
564
|
+
const rule = issue.rule?.name ?? "issue";
|
|
565
|
+
const file = toRepoRelative(issue.range?.filename, cwd);
|
|
566
|
+
const line = issue.range?.start?.line ?? null;
|
|
567
|
+
return {
|
|
568
|
+
id: concernId("tflint", rule, file, line),
|
|
569
|
+
source: "tflint",
|
|
570
|
+
rule_id: `tflint:${rule}`,
|
|
571
|
+
severity: tflintSeverity(issue.rule?.severity),
|
|
572
|
+
category: "style",
|
|
573
|
+
evidence: issue.message ?? rule,
|
|
574
|
+
location: { file, line },
|
|
575
|
+
remediation_hint: issue.rule?.link ?? null,
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
interface TflintIssue {
|
|
581
|
+
rule?: { name?: string; severity?: string; link?: string };
|
|
582
|
+
message?: string;
|
|
583
|
+
range?: { filename?: string; start?: { line?: number } };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// --- trivy ----------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
// tfsec was archived by Aqua and folded into Trivy; `trivy config` is its
|
|
589
|
+
// maintained successor with a larger ruleset (the AVD-* checks). `--quiet`
|
|
590
|
+
// keeps Trivy's progress chatter off stdout so the JSON parses cleanly.
|
|
591
|
+
function scanTrivy(cwd: string): ScannerOutcome {
|
|
592
|
+
const r = run("trivy", ["config", "--format", "json", "--quiet", "."], cwd);
|
|
593
|
+
if (r.missing) return skipped("trivy", "trivy not installed");
|
|
594
|
+
try {
|
|
595
|
+
return { source: "trivy", ran: true, concerns: parseTrivyOutput(r.stdout, cwd) };
|
|
596
|
+
} catch {
|
|
597
|
+
return skipped("trivy", "could not parse trivy json output");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Parse `trivy config --format json` output into concerns. Trivy nests
|
|
603
|
+
* misconfigurations under `Results[].Misconfigurations[]`, keyed to the result's
|
|
604
|
+
* `Target` file. `trivy config` reports only failures by default, but we
|
|
605
|
+
* defensively drop any `Status: "PASS"` entry so an `--include-non-failures`
|
|
606
|
+
* run can't leak passing checks into the concern set.
|
|
607
|
+
*/
|
|
608
|
+
export function parseTrivyOutput(stdout: string, cwd = ""): Concern[] {
|
|
609
|
+
const parsed = JSON.parse(stdout || "{}") as { Results?: TrivyResult[] | null };
|
|
610
|
+
const concerns: Concern[] = [];
|
|
611
|
+
for (const result of parsed.Results ?? []) {
|
|
612
|
+
const file = toRepoRelative(result.Target, cwd);
|
|
613
|
+
for (const m of result.Misconfigurations ?? []) {
|
|
614
|
+
if (m.Status === "PASS") continue;
|
|
615
|
+
const rule = m.AVDID || m.ID || "issue";
|
|
616
|
+
const start = m.CauseMetadata?.StartLine;
|
|
617
|
+
const line = typeof start === "number" && start > 0 ? start : null;
|
|
618
|
+
concerns.push({
|
|
619
|
+
id: concernId("trivy", rule, file, line),
|
|
620
|
+
source: "trivy",
|
|
621
|
+
rule_id: `trivy:${rule}`,
|
|
622
|
+
severity: lowerSeverity(m.Severity),
|
|
623
|
+
category: "security",
|
|
624
|
+
evidence: m.Message || m.Description || m.Title || rule,
|
|
625
|
+
location: { file, line },
|
|
626
|
+
remediation_hint: m.Resolution || m.References?.[0] || null,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return concerns;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
interface TrivyMisconfiguration {
|
|
634
|
+
ID?: string;
|
|
635
|
+
AVDID?: string;
|
|
636
|
+
Title?: string;
|
|
637
|
+
Description?: string;
|
|
638
|
+
Message?: string;
|
|
639
|
+
Resolution?: string;
|
|
640
|
+
Severity?: string;
|
|
641
|
+
References?: string[];
|
|
642
|
+
Status?: string;
|
|
643
|
+
CauseMetadata?: { StartLine?: number; EndLine?: number };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
interface TrivyResult {
|
|
647
|
+
Target?: string;
|
|
648
|
+
Class?: string;
|
|
649
|
+
Type?: string;
|
|
650
|
+
Misconfigurations?: TrivyMisconfiguration[];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// --- checkov --------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
function scanCheckov(cwd: string): ScannerOutcome {
|
|
656
|
+
// `--framework terraform` keeps checkov to Terraform only. By default checkov
|
|
657
|
+
// also scans github_actions / dockerfile / secrets / kubernetes / etc., which
|
|
658
|
+
// surfaces concerns in files Terramend can never remediate (the path guardrail
|
|
659
|
+
// blocks anything outside *.tf/*.tfvars) — pure noise. Terramend is
|
|
660
|
+
// Terraform-only, so we scope the scanner to match.
|
|
661
|
+
const r = run(
|
|
662
|
+
"checkov",
|
|
663
|
+
["-d", ".", "--framework", "terraform", "-o", "json", "--compact", "--quiet"],
|
|
664
|
+
cwd,
|
|
665
|
+
);
|
|
666
|
+
if (r.missing) return skipped("checkov", "checkov not installed");
|
|
667
|
+
try {
|
|
668
|
+
return { source: "checkov", ran: true, concerns: parseCheckovOutput(r.stdout, cwd) };
|
|
669
|
+
} catch {
|
|
670
|
+
return skipped("checkov", "could not parse checkov json output");
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** parse `checkov -o json` output (object for one framework, array for several). */
|
|
675
|
+
export function parseCheckovOutput(stdout: string, cwd = ""): Concern[] {
|
|
676
|
+
const parsed = JSON.parse(stdout || "{}") as CheckovOutput | CheckovOutput[];
|
|
677
|
+
const blocks = Array.isArray(parsed) ? parsed : [parsed];
|
|
678
|
+
const concerns: Concern[] = [];
|
|
679
|
+
for (const block of blocks) {
|
|
680
|
+
for (const check of block.results?.failed_checks ?? []) {
|
|
681
|
+
const file = toRepoRelative(check.file_path, cwd);
|
|
682
|
+
// checkov emits 0 for "no specific line"; normalize to null (matching the
|
|
683
|
+
// trivy parser and the reviewer's findings.json) so the content id is
|
|
684
|
+
// stable and a reviewer-loaded checkov concern re-verifies ✗→✓.
|
|
685
|
+
const startLine = check.file_line_range?.[0];
|
|
686
|
+
const line = typeof startLine === "number" && startLine > 0 ? startLine : null;
|
|
687
|
+
const rule = check.check_id ?? "issue";
|
|
688
|
+
concerns.push({
|
|
689
|
+
id: concernId("checkov", rule, file, line),
|
|
690
|
+
source: "checkov",
|
|
691
|
+
rule_id: `checkov:${rule}`,
|
|
692
|
+
severity: lowerSeverity(check.severity ?? undefined),
|
|
693
|
+
category: "security",
|
|
694
|
+
evidence: check.check_name ?? rule,
|
|
695
|
+
location: { file, line },
|
|
696
|
+
remediation_hint: check.guideline ?? null,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return concerns;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
interface CheckovOutput {
|
|
704
|
+
results?: {
|
|
705
|
+
failed_checks?: {
|
|
706
|
+
check_id?: string;
|
|
707
|
+
check_name?: string;
|
|
708
|
+
severity?: string | null;
|
|
709
|
+
file_path?: string;
|
|
710
|
+
file_line_range?: number[];
|
|
711
|
+
guideline?: string;
|
|
712
|
+
}[];
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Terraform files changed on the current branch vs the base. Returns null when
|
|
718
|
+
* the base can't be determined (caller then falls back to a full scan).
|
|
719
|
+
*/
|
|
720
|
+
export function changedTerraformFiles(cwd: string): Set<string> | null {
|
|
721
|
+
const base = resolveBaseRef(cwd);
|
|
722
|
+
if (!base) return null;
|
|
723
|
+
const mergeBase = run("git", ["merge-base", base, "HEAD"], cwd);
|
|
724
|
+
const from = mergeBase.status === 0 && mergeBase.stdout.trim() ? mergeBase.stdout.trim() : base;
|
|
725
|
+
const diff = run("git", ["diff", "--name-only", from, "HEAD"], cwd);
|
|
726
|
+
if (diff.status !== 0) return null;
|
|
727
|
+
// `git diff` reports paths relative to the repo ROOT, but a concern's
|
|
728
|
+
// `location.file` is relative to the scan `cwd` (toRepoRelative). When `cwd`
|
|
729
|
+
// is a repo SUBDIRECTORY (the `cwd` action input resolved under
|
|
730
|
+
// GITHUB_WORKSPACE) the two path spaces disagree — e.g. git says
|
|
731
|
+
// `infra/main.tf` while the concern says `main.tf` — and the in-scope check
|
|
732
|
+
// would silently drop every concern. Re-base the diff paths onto `cwd` by
|
|
733
|
+
// stripping the cwd→root prefix and discarding anything outside it, so both
|
|
734
|
+
// sides are cwd-relative.
|
|
735
|
+
const prefixResult = run("git", ["rev-parse", "--show-prefix"], cwd);
|
|
736
|
+
const prefix = prefixResult.status === 0 ? prefixResult.stdout.trim().replace(/\\/g, "/") : "";
|
|
737
|
+
const files: string[] = [];
|
|
738
|
+
for (const raw of diff.stdout.split("\n")) {
|
|
739
|
+
let f = raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
740
|
+
if (!f) continue;
|
|
741
|
+
if (prefix) {
|
|
742
|
+
if (!f.startsWith(prefix)) continue; // changed file lives outside the scanned subdir
|
|
743
|
+
f = f.slice(prefix.length);
|
|
744
|
+
}
|
|
745
|
+
if (f.endsWith(".tf") || f.endsWith(".tfvars")) files.push(f);
|
|
746
|
+
}
|
|
747
|
+
return new Set(files);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** run every scanner once over `cwd`. shared by `terraform_scan` and the
|
|
751
|
+
* deterministic remediation verifier so both see the identical toolchain. */
|
|
752
|
+
export function runScanners(cwd: string): ScannerOutcome[] {
|
|
753
|
+
return [scanFmt(cwd), scanValidate(cwd), scanTflint(cwd), scanTrivy(cwd), scanCheckov(cwd)];
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export interface RemediationVerdict {
|
|
757
|
+
/** true only when every original concern id is absent from the re-scan. */
|
|
758
|
+
verified: boolean;
|
|
759
|
+
/** original ids no longer present (the fix cleared them). */
|
|
760
|
+
resolved: string[];
|
|
761
|
+
/** original ids still present (the fix did NOT clear them). */
|
|
762
|
+
remaining: string[];
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Deterministic ✗→✓ check: partition the group's original `concern_ids` into
|
|
767
|
+
* those gone from a fresh scan (`resolved`) and those still present
|
|
768
|
+
* (`remaining`). Concern ids are content hashes (`sha1(source|rule|file|line)`),
|
|
769
|
+
* so a missing id means that exact concern is gone — the correct primitive for
|
|
770
|
+
* "did the fix clear it", independent of severity/scope filtering. This is the
|
|
771
|
+
* code-level replacement for the agent eyeballing a re-scan and self-reporting.
|
|
772
|
+
*/
|
|
773
|
+
export function computeRemediationVerdict(
|
|
774
|
+
originalConcernIds: string[],
|
|
775
|
+
currentConcernIds: Set<string>,
|
|
776
|
+
): RemediationVerdict {
|
|
777
|
+
const resolved: string[] = [];
|
|
778
|
+
const remaining: string[] = [];
|
|
779
|
+
for (const id of originalConcernIds) {
|
|
780
|
+
if (currentConcernIds.has(id)) remaining.push(id);
|
|
781
|
+
else resolved.push(id);
|
|
782
|
+
}
|
|
783
|
+
return { verified: remaining.length === 0, resolved, remaining };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* §1.4 Regression guard. The full re-scan (`terraform_verify_remediation`)
|
|
788
|
+
* already sees the whole workspace, so a concern the fix *introduced* shows up
|
|
789
|
+
* in the current scan. Regressions are exactly the content ids present after the
|
|
790
|
+
* fix that were not in the pre-fix baseline — `current − baseline`. A non-empty
|
|
791
|
+
* result means the fix traded one defect for another (e.g. an encryption block
|
|
792
|
+
* that trips a different tflint rule) and must downgrade the PR to needs-human.
|
|
793
|
+
*
|
|
794
|
+
* Both id sets are computed the same way (the deduped union of every scanner's
|
|
795
|
+
* concern ids, unfiltered by severity) so the diff is apples-to-apples — a
|
|
796
|
+
* regression at ANY severity is caught, not just ones above the run threshold.
|
|
797
|
+
* Returns sorted ids for a stable PR body.
|
|
798
|
+
*/
|
|
799
|
+
export function computeRegressions(
|
|
800
|
+
baselineConcernIds: Iterable<string>,
|
|
801
|
+
currentConcernIds: Iterable<string>,
|
|
802
|
+
): string[] {
|
|
803
|
+
const baseline = new Set(baselineConcernIds);
|
|
804
|
+
const regressions = new Set<string>();
|
|
805
|
+
for (const id of currentConcernIds) {
|
|
806
|
+
if (!baseline.has(id)) regressions.add(id);
|
|
807
|
+
}
|
|
808
|
+
return [...regressions].sort();
|
|
809
|
+
}
|