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,333 @@
|
|
|
1
|
+
import { ruleDocUrl } from "#app/mcp/terraform/decisions";
|
|
2
|
+
import {
|
|
3
|
+
type Concern,
|
|
4
|
+
concernId,
|
|
5
|
+
isTerraformFile,
|
|
6
|
+
lowerSeverity,
|
|
7
|
+
type Severity,
|
|
8
|
+
toRepoRelative,
|
|
9
|
+
} from "#app/mcp/terraform/types";
|
|
10
|
+
|
|
11
|
+
// --- reviewer findings (read_findings) ------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The subset of a terraform-reviewer (Assessor) `findings.json` finding that
|
|
15
|
+
* Terramend consumes. The reviewer's contract is a deliberate SUPERSET of the
|
|
16
|
+
* Concern model (same content-id formula, same severity enum) — see
|
|
17
|
+
* ../terraform-reviewer/schemas/findings.schema.json. Extra fields
|
|
18
|
+
* (lens/standard/control_id/confidence/state) are ignored except `state`.
|
|
19
|
+
*/
|
|
20
|
+
interface ReviewerFinding {
|
|
21
|
+
category?: string;
|
|
22
|
+
source?: string;
|
|
23
|
+
rule_id?: string;
|
|
24
|
+
/** verified (deterministic) | evidence (confirm manually) | human_only (out of scope). */
|
|
25
|
+
state?: string;
|
|
26
|
+
severity?: string;
|
|
27
|
+
evidence?: string;
|
|
28
|
+
location?: { file?: string; line?: number | null };
|
|
29
|
+
remediation_hint?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ReviewerFindingsReport {
|
|
33
|
+
schema_version?: string;
|
|
34
|
+
findings?: ReviewerFinding[] | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Map a reviewer `source` to a Concern source. The scanners Terramend also runs
|
|
39
|
+
* (checkov / tflint / trivy / terraform-fmt / terraform-validate) keep their
|
|
40
|
+
* name, so re-running them reproduces the identical content id — those concerns
|
|
41
|
+
* are ✗→✓ verifiable. Everything else (tfsec — whose rule ids differ from
|
|
42
|
+
* trivy's — plus infracost, llm, …) collapses to `reviewer`: the original tool
|
|
43
|
+
* stays visible in `rule_id`, but Terramend can't reproduce the id, so
|
|
44
|
+
* `terraform_verify_remediation` will honestly report it unresolved.
|
|
45
|
+
*
|
|
46
|
+
* NB: trivy ✗→✓ verifiability assumes the reviewer's trivy `rule_id` equals
|
|
47
|
+
* Terramend's (both `trivy:<AVDID>`). That holds today (the reviewer's SARIF
|
|
48
|
+
* `ruleId` is the AVD id); if a future Trivy diverges SARIF ruleId from the
|
|
49
|
+
* JSON AVDID, trivy-source findings would stop matching on re-scan.
|
|
50
|
+
*/
|
|
51
|
+
function mapReviewerSource(source: string | undefined): Concern["source"] {
|
|
52
|
+
switch (source) {
|
|
53
|
+
case "checkov":
|
|
54
|
+
case "tflint":
|
|
55
|
+
case "trivy":
|
|
56
|
+
case "terraform-fmt":
|
|
57
|
+
case "terraform-validate":
|
|
58
|
+
return source;
|
|
59
|
+
default:
|
|
60
|
+
return "reviewer";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mapReviewerCategory(category: string | undefined): Concern["category"] {
|
|
65
|
+
switch (category) {
|
|
66
|
+
case "security":
|
|
67
|
+
return "security";
|
|
68
|
+
case "style":
|
|
69
|
+
return "style";
|
|
70
|
+
case "cost":
|
|
71
|
+
return "cost";
|
|
72
|
+
default:
|
|
73
|
+
return "correctness";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Map a reviewer `findings.json` body into Concern[]. Drops `human_only`
|
|
79
|
+
* findings (out of scope — not auto-remediable). Paths are normalized to
|
|
80
|
+
* repo-relative POSIX (same as the scanners) so ids and grouping stay portable.
|
|
81
|
+
*/
|
|
82
|
+
export function parseReviewerFindings(json: string, cwd = ""): Concern[] {
|
|
83
|
+
const parsed = JSON.parse(json || "{}") as ReviewerFindingsReport;
|
|
84
|
+
const out: Concern[] = [];
|
|
85
|
+
for (const f of parsed.findings ?? []) {
|
|
86
|
+
if (f.state === "human_only") continue;
|
|
87
|
+
const file = toRepoRelative(f.location?.file, cwd);
|
|
88
|
+
// Skip findings that don't point at a Terraform file — they aren't per-file
|
|
89
|
+
// remediable. In particular the reviewer's infracost/cost findings are keyed
|
|
90
|
+
// to a project *directory* (not a `.tf`), so they land here; cost is surfaced
|
|
91
|
+
// during remediation by `infracost_diff` (E1), not by editing a directory.
|
|
92
|
+
if (!isTerraformFile(file)) continue;
|
|
93
|
+
const line = f.location?.line ?? null;
|
|
94
|
+
const source = mapReviewerSource(f.source);
|
|
95
|
+
const ruleId = f.rule_id || "finding";
|
|
96
|
+
// Terramend's own parsers store `rule_id` namespaced (`${source}:${rule}`)
|
|
97
|
+
// but hash only the bare rule into the content id. Strip a matching
|
|
98
|
+
// `${source}:` prefix so a checkov/tflint/trivy/fmt finding from the reviewer
|
|
99
|
+
// reproduces the SAME id Terramend's own scan would — keeping it ✗→✓
|
|
100
|
+
// verifiable. `reviewer`-source findings keep the full rule_id in the hash
|
|
101
|
+
// (they aren't reproducible anyway). Both namespaced and bare inputs work.
|
|
102
|
+
const bareRule =
|
|
103
|
+
source !== "reviewer" && ruleId.startsWith(`${source}:`)
|
|
104
|
+
? ruleId.slice(source.length + 1)
|
|
105
|
+
: ruleId;
|
|
106
|
+
out.push({
|
|
107
|
+
id: concernId(source, bareRule, file, line),
|
|
108
|
+
source,
|
|
109
|
+
rule_id: ruleId,
|
|
110
|
+
severity: lowerSeverity(f.severity),
|
|
111
|
+
category: mapReviewerCategory(f.category),
|
|
112
|
+
evidence: f.evidence || ruleId,
|
|
113
|
+
location: { file, line },
|
|
114
|
+
remediation_hint: f.remediation_hint ?? null,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- SARIF ingestion (read_findings) --------------------------------------
|
|
121
|
+
|
|
122
|
+
interface SarifLocation {
|
|
123
|
+
physicalLocation?: {
|
|
124
|
+
artifactLocation?: { uri?: string };
|
|
125
|
+
region?: { startLine?: number };
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
interface SarifResult {
|
|
129
|
+
ruleId?: string;
|
|
130
|
+
level?: string;
|
|
131
|
+
message?: { text?: string };
|
|
132
|
+
locations?: SarifLocation[];
|
|
133
|
+
properties?: { "security-severity"?: string };
|
|
134
|
+
}
|
|
135
|
+
interface SarifRule {
|
|
136
|
+
id?: string;
|
|
137
|
+
helpUri?: string;
|
|
138
|
+
shortDescription?: { text?: string };
|
|
139
|
+
}
|
|
140
|
+
interface SarifRun {
|
|
141
|
+
tool?: { driver?: { name?: string; rules?: SarifRule[] } };
|
|
142
|
+
results?: SarifResult[];
|
|
143
|
+
}
|
|
144
|
+
interface SarifReport {
|
|
145
|
+
version?: string;
|
|
146
|
+
$schema?: string;
|
|
147
|
+
runs?: SarifRun[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** true when a parsed JSON object looks like a SARIF report (the standard
|
|
151
|
+
* scanner-output format) rather than a terraform-reviewer findings.json. */
|
|
152
|
+
export function isSarif(parsed: unknown): boolean {
|
|
153
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
154
|
+
const o = parsed as Record<string, unknown>;
|
|
155
|
+
const schema = typeof o.$schema === "string" ? o.$schema.toLowerCase() : "";
|
|
156
|
+
return Array.isArray(o.runs) && (schema.includes("sarif") || typeof o.version === "string");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** map a SARIF driver name to a Concern source (so a re-run reproduces the id
|
|
160
|
+
* for the scanners Terramend also runs; everything else → `reviewer`). */
|
|
161
|
+
function mapSarifDriver(name: string | undefined): Concern["source"] {
|
|
162
|
+
switch ((name ?? "").toLowerCase()) {
|
|
163
|
+
case "trivy":
|
|
164
|
+
return "trivy";
|
|
165
|
+
case "checkov":
|
|
166
|
+
return "checkov";
|
|
167
|
+
case "tflint":
|
|
168
|
+
return "tflint";
|
|
169
|
+
default:
|
|
170
|
+
return "reviewer";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** SARIF `level` → severity (a `security-severity` property refines it). */
|
|
175
|
+
function sarifSeverity(level: string | undefined, securitySeverity: string | undefined): Severity {
|
|
176
|
+
const score = securitySeverity ? Number.parseFloat(securitySeverity) : Number.NaN;
|
|
177
|
+
if (Number.isFinite(score)) {
|
|
178
|
+
if (score >= 9) return "critical";
|
|
179
|
+
if (score >= 7) return "high";
|
|
180
|
+
if (score >= 4) return "medium";
|
|
181
|
+
if (score > 0) return "low";
|
|
182
|
+
}
|
|
183
|
+
switch ((level ?? "").toLowerCase()) {
|
|
184
|
+
case "error":
|
|
185
|
+
return "high";
|
|
186
|
+
case "warning":
|
|
187
|
+
return "medium";
|
|
188
|
+
case "note":
|
|
189
|
+
return "low";
|
|
190
|
+
default:
|
|
191
|
+
return "info";
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Parse a SARIF 2.1.0 report (the standard scanner-output format Trivy /
|
|
197
|
+
* Checkov / tflint all emit) into Concern[]. The driver name picks the source so
|
|
198
|
+
* a finding from a scanner Terramend re-runs reproduces the SAME content id
|
|
199
|
+
* (✗→✓ verifiable); other tools collapse to `reviewer`. Rule docs come from the
|
|
200
|
+
* matching `tool.driver.rules[].helpUri`. Non-Terraform files are dropped.
|
|
201
|
+
*/
|
|
202
|
+
export function parseSarifFindings(json: string, cwd = ""): Concern[] {
|
|
203
|
+
let report: SarifReport;
|
|
204
|
+
try {
|
|
205
|
+
report = JSON.parse(json || "{}") as SarifReport;
|
|
206
|
+
} catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
const out: Concern[] = [];
|
|
210
|
+
for (const run of report.runs ?? []) {
|
|
211
|
+
const source = mapSarifDriver(run.tool?.driver?.name);
|
|
212
|
+
const ruleDocs = new Map<string, string>();
|
|
213
|
+
for (const rule of run.tool?.driver?.rules ?? []) {
|
|
214
|
+
if (rule.id && rule.helpUri) ruleDocs.set(rule.id, rule.helpUri);
|
|
215
|
+
}
|
|
216
|
+
for (const result of run.results ?? []) {
|
|
217
|
+
const loc = result.locations?.[0]?.physicalLocation;
|
|
218
|
+
const file = toRepoRelative(loc?.artifactLocation?.uri, cwd);
|
|
219
|
+
if (!isTerraformFile(file)) continue;
|
|
220
|
+
const start = loc?.region?.startLine;
|
|
221
|
+
const line = typeof start === "number" && start > 0 ? start : null;
|
|
222
|
+
const rawRule = result.ruleId || "finding";
|
|
223
|
+
// strip a `${source}:` prefix if a tool already namespaced it, so the
|
|
224
|
+
// content id matches Terramend's own scan of the same rule.
|
|
225
|
+
const bareRule =
|
|
226
|
+
source !== "reviewer" && rawRule.startsWith(`${source}:`)
|
|
227
|
+
? rawRule.slice(source.length + 1)
|
|
228
|
+
: rawRule;
|
|
229
|
+
const ruleId = source === "reviewer" ? rawRule : `${source}:${bareRule}`;
|
|
230
|
+
out.push({
|
|
231
|
+
id: concernId(source, bareRule, file, line),
|
|
232
|
+
source,
|
|
233
|
+
rule_id: ruleId,
|
|
234
|
+
severity: sarifSeverity(result.level, result.properties?.["security-severity"]),
|
|
235
|
+
category: source === "tflint" ? "style" : "security",
|
|
236
|
+
evidence: result.message?.text || ruleId,
|
|
237
|
+
location: { file, line },
|
|
238
|
+
remediation_hint: ruleDocs.get(rawRule) ?? null,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** dispatch a findings file to the right parser: SARIF (standard scanner
|
|
246
|
+
* output) or a terraform-reviewer findings.json. */
|
|
247
|
+
export function parseFindingsFile(json: string, cwd = ""): Concern[] {
|
|
248
|
+
let parsed: unknown;
|
|
249
|
+
try {
|
|
250
|
+
parsed = JSON.parse(json || "{}");
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
return isSarif(parsed) ? parseSarifFindings(json, cwd) : parseReviewerFindings(json, cwd);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- SARIF emit (GitHub code-scanning) ------------------------------------
|
|
258
|
+
|
|
259
|
+
/** Concern severity → SARIF `level`. SARIF has only error/warning/note, so
|
|
260
|
+
* critical+high collapse to `error`, medium → `warning`, low+info → `note`. The
|
|
261
|
+
* finer grade survives in the `security-severity` property below. */
|
|
262
|
+
function severityToSarifLevel(s: Severity): "error" | "warning" | "note" {
|
|
263
|
+
switch (s) {
|
|
264
|
+
case "critical":
|
|
265
|
+
case "high":
|
|
266
|
+
return "error";
|
|
267
|
+
case "medium":
|
|
268
|
+
return "warning";
|
|
269
|
+
default:
|
|
270
|
+
return "note";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Concern severity → the numeric `security-severity` GitHub reads to colour the
|
|
275
|
+
* alert (0–10 CVSS-like scale). */
|
|
276
|
+
function securitySeverityScore(s: Severity): string {
|
|
277
|
+
switch (s) {
|
|
278
|
+
case "critical":
|
|
279
|
+
return "9.5";
|
|
280
|
+
case "high":
|
|
281
|
+
return "8.0";
|
|
282
|
+
case "medium":
|
|
283
|
+
return "5.0";
|
|
284
|
+
case "low":
|
|
285
|
+
return "2.0";
|
|
286
|
+
default:
|
|
287
|
+
return "0.0";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Emit a set of concerns as a SARIF 2.1.0 report for GitHub code-scanning (the
|
|
293
|
+
* inverse of `parseSarifFindings` — close the loop so a Terramend scan can
|
|
294
|
+
* populate the repo's Security tab via `github/codeql-action/upload-sarif`). One
|
|
295
|
+
* `run` with the `terramend` driver, a deduped `rules` array (each rule's
|
|
296
|
+
* `helpUri` from `ruleDocUrl`), and one `result` per concern carrying its
|
|
297
|
+
* `level`, `security-severity`, message, and `file:line`. Pure + deterministic
|
|
298
|
+
* (rules sorted, stable partialFingerprints from the content id) so re-emitting
|
|
299
|
+
* an unchanged scan yields a byte-identical report.
|
|
300
|
+
*/
|
|
301
|
+
export function buildSarifReport(concerns: Concern[]): SarifReport {
|
|
302
|
+
// deduped rule metadata, keyed by the namespaced rule_id (the SARIF ruleId).
|
|
303
|
+
const rulesById = new Map<string, SarifRule>();
|
|
304
|
+
for (const c of concerns) {
|
|
305
|
+
if (rulesById.has(c.rule_id)) continue;
|
|
306
|
+
const helpUri = ruleDocUrl(c);
|
|
307
|
+
rulesById.set(c.rule_id, {
|
|
308
|
+
id: c.rule_id,
|
|
309
|
+
...(helpUri ? { helpUri } : {}),
|
|
310
|
+
shortDescription: { text: c.evidence.slice(0, 200) },
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const rules = [...rulesById.values()].sort((a, b) => (a.id ?? "").localeCompare(b.id ?? ""));
|
|
314
|
+
const results: SarifResult[] = concerns.map((c) => ({
|
|
315
|
+
ruleId: c.rule_id,
|
|
316
|
+
level: severityToSarifLevel(c.severity),
|
|
317
|
+
message: { text: c.evidence },
|
|
318
|
+
properties: { "security-severity": securitySeverityScore(c.severity) },
|
|
319
|
+
locations: [
|
|
320
|
+
{
|
|
321
|
+
physicalLocation: {
|
|
322
|
+
artifactLocation: { uri: c.location.file },
|
|
323
|
+
...(c.location.line ? { region: { startLine: c.location.line } } : {}),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
}));
|
|
328
|
+
return {
|
|
329
|
+
version: "2.1.0",
|
|
330
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
331
|
+
runs: [{ tool: { driver: { name: "terramend", rules } }, results }],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { BlastTier } from "#app/mcp/terraform/types";
|
|
2
|
+
|
|
3
|
+
// --- terraform plan (the safety gate) -------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface PlanSummary {
|
|
6
|
+
/** resources to add / change / destroy, from the plan's change_summary. */
|
|
7
|
+
add: number;
|
|
8
|
+
change: number;
|
|
9
|
+
destroy: number;
|
|
10
|
+
/** every resource with a real action (create/update/delete/replace) — the set
|
|
11
|
+
* that powers blast-radius (§2.6) and plan-stability (§1.3). */
|
|
12
|
+
changed: { address: string; action: string }[];
|
|
13
|
+
/** resources that would be deleted or replaced — the destructive set. */
|
|
14
|
+
destructive: { address: string; action: string }[];
|
|
15
|
+
hasDestroyOrReplace: boolean;
|
|
16
|
+
/** state-only moves (`moved {}` blocks / refactors): the new address and the
|
|
17
|
+
* address it came from. Moves don't mutate live infrastructure, so they are
|
|
18
|
+
* NOT in `changed` — they power the M2 modularization gate (`isPureMovePlan`). */
|
|
19
|
+
moved: { address: string; previousAddress: string | null }[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse `terraform plan -json` (newline-delimited JSON). `change_summary` gives
|
|
24
|
+
* the add/change/destroy totals; each `planned_change` with a real action is
|
|
25
|
+
* collected into `changed`, and the delete/replace subset into `destructive`
|
|
26
|
+
* (the high-risk set a reviewer must scrutinise). Non-mutating actions are
|
|
27
|
+
* ignored, as are non-JSON / non-plan lines, so a noisy stream (provider logs,
|
|
28
|
+
* diagnostics) parses cleanly.
|
|
29
|
+
*
|
|
30
|
+
* NB on the action enum: terraform's machine-readable UI (the `-json` stream)
|
|
31
|
+
* spells no-op as `"noop"` — NOT `"no-op"` — and also emits `"move"` / `"import"`
|
|
32
|
+
* / `"forget"` for state-only operations that don't mutate live infrastructure.
|
|
33
|
+
* None of those should count toward `changed` (they'd inflate the blast radius
|
|
34
|
+
* §2.6). We skip them explicitly; `"no-op"` is tolerated too in case a wrapper
|
|
35
|
+
* or older format hyphenates it. See
|
|
36
|
+
* https://developer.hashicorp.com/terraform/internals/machine-readable-ui.
|
|
37
|
+
*/
|
|
38
|
+
const NON_MUTATING_PLAN_ACTIONS: ReadonlySet<string> = new Set([
|
|
39
|
+
"noop",
|
|
40
|
+
"no-op",
|
|
41
|
+
"read",
|
|
42
|
+
"move",
|
|
43
|
+
"import",
|
|
44
|
+
"forget",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export function parseTerraformPlanJson(stdout: string): PlanSummary {
|
|
48
|
+
let add = 0;
|
|
49
|
+
let change = 0;
|
|
50
|
+
let destroy = 0;
|
|
51
|
+
const changed: { address: string; action: string }[] = [];
|
|
52
|
+
const destructive: { address: string; action: string }[] = [];
|
|
53
|
+
const moved: { address: string; previousAddress: string | null }[] = [];
|
|
54
|
+
for (const line of stdout.split("\n")) {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
if (!trimmed.startsWith("{")) continue;
|
|
57
|
+
let msg: {
|
|
58
|
+
type?: string;
|
|
59
|
+
changes?: { add?: number; change?: number; remove?: number };
|
|
60
|
+
change?: {
|
|
61
|
+
action?: string;
|
|
62
|
+
resource?: { addr?: string; resource?: string };
|
|
63
|
+
previous_resource?: { addr?: string; resource?: string };
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
msg = JSON.parse(trimmed);
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (msg.type === "change_summary" && msg.changes) {
|
|
72
|
+
add = Number(msg.changes.add) || 0;
|
|
73
|
+
change = Number(msg.changes.change) || 0;
|
|
74
|
+
destroy = Number(msg.changes.remove) || 0;
|
|
75
|
+
} else if (msg.type === "planned_change" && msg.change) {
|
|
76
|
+
const action = String(msg.change.action ?? "");
|
|
77
|
+
// moves are state-only (no infra mutation) but are tracked separately —
|
|
78
|
+
// they prove an M2 modularization refactor is a no-op (`isPureMovePlan`).
|
|
79
|
+
if (action === "move") {
|
|
80
|
+
const address = msg.change.resource?.addr || msg.change.resource?.resource || "(unknown)";
|
|
81
|
+
const previousAddress =
|
|
82
|
+
msg.change.previous_resource?.addr || msg.change.previous_resource?.resource || null;
|
|
83
|
+
moved.push({ address, previousAddress });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!action || NON_MUTATING_PLAN_ACTIONS.has(action)) continue;
|
|
87
|
+
const address = msg.change.resource?.addr || msg.change.resource?.resource || "(unknown)";
|
|
88
|
+
changed.push({ address, action });
|
|
89
|
+
// "delete", "replace", and the "*-then-delete" / "delete-then-*" forms.
|
|
90
|
+
if (action.includes("delete") || action === "replace") {
|
|
91
|
+
destructive.push({ address, action });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
add,
|
|
97
|
+
change,
|
|
98
|
+
destroy,
|
|
99
|
+
changed,
|
|
100
|
+
destructive,
|
|
101
|
+
hasDestroyOrReplace: destructive.length > 0,
|
|
102
|
+
moved,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* §M2 modularization gate — true when the plan is PURELY state moves: at least
|
|
108
|
+
* one `moved` entry and zero create/update/delete/replace. That proves a
|
|
109
|
+
* resources→module refactor preserved every resource address (via `moved {}`
|
|
110
|
+
* blocks) and is a no-op on live infrastructure — the condition under which a
|
|
111
|
+
* modularization PR may proceed without human escalation.
|
|
112
|
+
*/
|
|
113
|
+
export function isPureMovePlan(plan: {
|
|
114
|
+
add: number;
|
|
115
|
+
change: number;
|
|
116
|
+
destroy: number;
|
|
117
|
+
changed: { address: string }[];
|
|
118
|
+
moved: { address: string }[];
|
|
119
|
+
}): boolean {
|
|
120
|
+
return (
|
|
121
|
+
plan.moved.length > 0 &&
|
|
122
|
+
plan.changed.length === 0 &&
|
|
123
|
+
plan.add + plan.change + plan.destroy === 0
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- stateful destroy/replace classification (safety gate §2.5) ------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resource types that hold data/state — destroying or replacing one of these
|
|
131
|
+
* means data loss, not just recreation. A remediation that would delete or
|
|
132
|
+
* replace one is hard-blocked at push time unless the operator opts in via the
|
|
133
|
+
* `allow_replace` input. Not exhaustive: it covers the common managed
|
|
134
|
+
* datastores across AWS / Azure / GCP; extend as new ones come up.
|
|
135
|
+
*/
|
|
136
|
+
export const STATEFUL_RESOURCE_TYPES: ReadonlySet<string> = new Set([
|
|
137
|
+
// AWS
|
|
138
|
+
"aws_db_instance",
|
|
139
|
+
"aws_rds_cluster",
|
|
140
|
+
"aws_rds_cluster_instance",
|
|
141
|
+
"aws_s3_bucket",
|
|
142
|
+
"aws_ebs_volume",
|
|
143
|
+
"aws_efs_file_system",
|
|
144
|
+
"aws_dynamodb_table",
|
|
145
|
+
"aws_dynamodb_global_table",
|
|
146
|
+
"aws_elasticache_cluster",
|
|
147
|
+
"aws_elasticache_replication_group",
|
|
148
|
+
"aws_redshift_cluster",
|
|
149
|
+
"aws_docdb_cluster",
|
|
150
|
+
"aws_neptune_cluster",
|
|
151
|
+
"aws_opensearch_domain",
|
|
152
|
+
"aws_elasticsearch_domain",
|
|
153
|
+
// Azure
|
|
154
|
+
"azurerm_sql_database",
|
|
155
|
+
"azurerm_mssql_database",
|
|
156
|
+
"azurerm_postgresql_database",
|
|
157
|
+
"azurerm_postgresql_flexible_server",
|
|
158
|
+
"azurerm_mysql_database",
|
|
159
|
+
"azurerm_mysql_flexible_server",
|
|
160
|
+
"azurerm_cosmosdb_account",
|
|
161
|
+
"azurerm_cosmosdb_sql_database",
|
|
162
|
+
"azurerm_storage_account",
|
|
163
|
+
"azurerm_managed_disk",
|
|
164
|
+
// GCP
|
|
165
|
+
"google_sql_database_instance",
|
|
166
|
+
"google_storage_bucket",
|
|
167
|
+
"google_bigtable_instance",
|
|
168
|
+
"google_bigquery_dataset",
|
|
169
|
+
"google_spanner_database",
|
|
170
|
+
"google_redis_instance",
|
|
171
|
+
"google_filestore_instance",
|
|
172
|
+
"google_compute_disk",
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extract the Terraform resource TYPE from a plan address, stripping any
|
|
177
|
+
* `module.<name>.` prefixes and an instance index/key suffix:
|
|
178
|
+
* `module.db.aws_db_instance.main` -> `aws_db_instance`
|
|
179
|
+
* `aws_s3_bucket.data["prod"]` -> `aws_s3_bucket`
|
|
180
|
+
* `module.a.module.b.google_storage_bucket.x[0]` -> `google_storage_bucket`
|
|
181
|
+
* Returns "" when the address has no parseable `type.name` pair.
|
|
182
|
+
*/
|
|
183
|
+
export function resourceTypeOf(address: string): string {
|
|
184
|
+
const withoutModules = address.replace(/^(?:module\.[^.]+\.)+/, "");
|
|
185
|
+
const cleaned = withoutModules.replace(/\[[^\]]*\]$/, "");
|
|
186
|
+
const segments = cleaned.split(".");
|
|
187
|
+
return segments.at(-2) ?? "";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface DestroyClassification {
|
|
191
|
+
/** destroy/replace of a data-bearing type — high-risk, blocked by default. */
|
|
192
|
+
stateful: { address: string; action: string; type: string }[];
|
|
193
|
+
/** destroy/replace of a recreatable type — recorded, not blocked. */
|
|
194
|
+
ephemeral: { address: string; action: string; type: string }[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** partition a plan's destructive set into stateful (blocked) vs ephemeral. */
|
|
198
|
+
export function classifyDestructive(
|
|
199
|
+
destructive: { address: string; action: string }[],
|
|
200
|
+
): DestroyClassification {
|
|
201
|
+
const stateful: DestroyClassification["stateful"] = [];
|
|
202
|
+
const ephemeral: DestroyClassification["ephemeral"] = [];
|
|
203
|
+
for (const d of destructive) {
|
|
204
|
+
const type = resourceTypeOf(d.address);
|
|
205
|
+
(STATEFUL_RESOURCE_TYPES.has(type) ? stateful : ephemeral).push({ ...d, type });
|
|
206
|
+
}
|
|
207
|
+
return { stateful, ephemeral };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- blast-radius scoring (§2.6) -------------------------------------------
|
|
211
|
+
|
|
212
|
+
export interface BlastRadius {
|
|
213
|
+
tier: BlastTier;
|
|
214
|
+
/** count of resources the plan would create/update/delete/replace. */
|
|
215
|
+
resourceCount: number;
|
|
216
|
+
/** distinct module addresses touched (root resources count as `root`). */
|
|
217
|
+
modules: string[];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Extract the module address from a resource address: the `module.X[.module.Y]`
|
|
222
|
+
* call path, or `root` for a top-level resource. Strips instance index/key from
|
|
223
|
+
* EVERY segment — a `count`/`for_each` MODULE carries its key on the module
|
|
224
|
+
* segment (`module.net[0]`), so all instances of one module collapse to one
|
|
225
|
+
* address (else a single-module fix would look cross-module). Removing keys
|
|
226
|
+
* first also tolerates a `.` inside a `for_each` string key.
|
|
227
|
+
* `aws_s3_bucket.b` -> `root`
|
|
228
|
+
* `module.db.aws_db_instance.main` -> `module.db`
|
|
229
|
+
* `module.net[0].aws_vpc.main` -> `module.net`
|
|
230
|
+
* `module.a.module.b.google_x.y[0]` -> `module.a.module.b`
|
|
231
|
+
*/
|
|
232
|
+
export function moduleAddressOf(address: string): string {
|
|
233
|
+
const cleaned = address.replace(/\[[^\]]*\]/g, "");
|
|
234
|
+
const segments = cleaned.split(".");
|
|
235
|
+
// the resource is the final `type.name` pair; anything before is the module path.
|
|
236
|
+
return segments.length <= 2 ? "root" : segments.slice(0, segments.length - 2).join(".");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Score how much a fix touches, to route large changes through stricter review:
|
|
241
|
+
* 1–2 resources = `low`, 3–10 = `medium`, more than 10 OR spanning more than one
|
|
242
|
+
* module = `high`. A `high` blast radius should force human-in-the-loop
|
|
243
|
+
* regardless of finding severity (feeds §3.9). 0 changes is `low` (nothing to do).
|
|
244
|
+
*/
|
|
245
|
+
export function computeBlastRadius(changed: { address: string }[]): BlastRadius {
|
|
246
|
+
const resourceCount = changed.length;
|
|
247
|
+
const modules = [...new Set(changed.map((c) => moduleAddressOf(c.address)))].sort();
|
|
248
|
+
const crossModule = modules.length > 1;
|
|
249
|
+
let tier: BlastTier;
|
|
250
|
+
if (resourceCount > 10 || crossModule) tier = "high";
|
|
251
|
+
else if (resourceCount >= 3) tier = "medium";
|
|
252
|
+
else tier = "low";
|
|
253
|
+
return { tier, resourceCount, modules };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- plan stability / idempotency (§1.3) -----------------------------------
|
|
257
|
+
|
|
258
|
+
export interface StabilityResult {
|
|
259
|
+
/** true when a second plan produced the identical change set. */
|
|
260
|
+
stable: boolean;
|
|
261
|
+
reason?: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** a normalized signature of a plan's change set (summary counts + sorted
|
|
265
|
+
* address:action pairs) — two plans with the same signature are equivalent. */
|
|
266
|
+
function planSignature(s: PlanSummary): string {
|
|
267
|
+
const set = s.changed
|
|
268
|
+
.map((c) => `${c.address}:${c.action}`)
|
|
269
|
+
.sort()
|
|
270
|
+
.join(",");
|
|
271
|
+
return `+${s.add}~${s.change}-${s.destroy}|${set}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Compare two consecutive plans for stability. Terramend never `apply`s (it only
|
|
276
|
+
* opens PRs), so a true "no perpetual diff after apply" cannot be proven here —
|
|
277
|
+
* but a fix whose plan is non-deterministic (e.g. `timestamp()`, `uuid()`, an
|
|
278
|
+
* unkeyed `random_*`, or a data source that varies run-to-run) yields a DIFFERENT
|
|
279
|
+
* plan on the second run, and that is a real perpetual-diff smell we can catch
|
|
280
|
+
* without applying. Stable ⇒ the two plans matched; unstable ⇒ report it.
|
|
281
|
+
*/
|
|
282
|
+
export function comparePlanStability(first: PlanSummary, second: PlanSummary): StabilityResult {
|
|
283
|
+
if (planSignature(first) === planSignature(second)) return { stable: true };
|
|
284
|
+
return {
|
|
285
|
+
stable: false,
|
|
286
|
+
reason:
|
|
287
|
+
`the plan is not deterministic — a second \`terraform plan\` (same state, no apply) produced a ` +
|
|
288
|
+
`different change set (first: +${first.add} ~${first.change} -${first.destroy}; ` +
|
|
289
|
+
`second: +${second.add} ~${second.change} -${second.destroy}). This is a perpetual-diff smell, ` +
|
|
290
|
+
`usually a non-deterministic value in the config (timestamp()/uuid()/unkeyed random_*/a varying data source).`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- multi-root plan aggregation -------------------------------------------
|
|
295
|
+
|
|
296
|
+
export interface RootPlan {
|
|
297
|
+
/** display label for the root ("." for the top-level root). */
|
|
298
|
+
dir: string;
|
|
299
|
+
summary: PlanSummary;
|
|
300
|
+
stable: boolean;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface AggregatedPlan {
|
|
304
|
+
add: number;
|
|
305
|
+
change: number;
|
|
306
|
+
destroy: number;
|
|
307
|
+
changed: { address: string; action: string }[];
|
|
308
|
+
destructive: { address: string; action: string }[];
|
|
309
|
+
hasDestroyOrReplace: boolean;
|
|
310
|
+
idempotent: boolean;
|
|
311
|
+
moved: { address: string; previousAddress: string | null }[];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Aggregate per-root plan results into one view: SUM the add/change/destroy
|
|
316
|
+
* counts, UNION the changed + destructive sets (so blast-radius and the
|
|
317
|
+
* destroy-block see every root's effect), and treat the whole run as
|
|
318
|
+
* non-idempotent if ANY root's plan was unstable. Pure. Single-root input passes
|
|
319
|
+
* straight through (identical to the pre-multi-root behaviour).
|
|
320
|
+
*/
|
|
321
|
+
export function aggregatePlans(roots: RootPlan[]): AggregatedPlan {
|
|
322
|
+
let add = 0;
|
|
323
|
+
let change = 0;
|
|
324
|
+
let destroy = 0;
|
|
325
|
+
let idempotent = true;
|
|
326
|
+
const changed: { address: string; action: string }[] = [];
|
|
327
|
+
const destructive: { address: string; action: string }[] = [];
|
|
328
|
+
const moved: { address: string; previousAddress: string | null }[] = [];
|
|
329
|
+
for (const r of roots) {
|
|
330
|
+
add += r.summary.add;
|
|
331
|
+
change += r.summary.change;
|
|
332
|
+
destroy += r.summary.destroy;
|
|
333
|
+
changed.push(...r.summary.changed);
|
|
334
|
+
destructive.push(...r.summary.destructive);
|
|
335
|
+
moved.push(...r.summary.moved);
|
|
336
|
+
if (!r.stable) idempotent = false;
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
add,
|
|
340
|
+
change,
|
|
341
|
+
destroy,
|
|
342
|
+
changed,
|
|
343
|
+
destructive,
|
|
344
|
+
hasDestroyOrReplace: destructive.length > 0,
|
|
345
|
+
idempotent,
|
|
346
|
+
moved,
|
|
347
|
+
};
|
|
348
|
+
}
|