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,705 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
// the guardrails read git through `$` (#app/utils/shell) and run gitleaks via
|
|
7
|
+
// `spawnSync` — both are mocked so no real subprocess executes.
|
|
8
|
+
const shellMock = vi.hoisted(() => vi.fn());
|
|
9
|
+
const spawnSyncMock = vi.hoisted(() => vi.fn());
|
|
10
|
+
|
|
11
|
+
vi.mock("#app/utils/shell", async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal<typeof import("#app/utils/shell")>();
|
|
13
|
+
return { ...actual, $: shellMock };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
18
|
+
return { ...actual, spawnSync: spawnSyncMock };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
assertNoBlockedDestroy,
|
|
23
|
+
assertNoSecretsInDiff,
|
|
24
|
+
assertUnderPrCap,
|
|
25
|
+
DEFAULT_ALLOWED_PATHS,
|
|
26
|
+
enforceProtectedPaths,
|
|
27
|
+
enforceRemediationPaths,
|
|
28
|
+
GENERATE_MODE,
|
|
29
|
+
globToRegex,
|
|
30
|
+
isPathAllowed,
|
|
31
|
+
parseGitleaksReport,
|
|
32
|
+
REMEDIATE_MODE,
|
|
33
|
+
recordRemediationPrOpened,
|
|
34
|
+
resolveAllowedPaths,
|
|
35
|
+
scanDiffForSecrets,
|
|
36
|
+
TERRATEST_ALLOWED_PATHS,
|
|
37
|
+
} from "#app/mcp/guardrails";
|
|
38
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
39
|
+
import { log } from "#app/utils/cli";
|
|
40
|
+
|
|
41
|
+
describe("globToRegex", () => {
|
|
42
|
+
it("matches **/*.tf at any depth", () => {
|
|
43
|
+
const re = globToRegex("**/*.tf");
|
|
44
|
+
expect(re.test("main.tf")).toBe(true);
|
|
45
|
+
expect(re.test("modules/net/vpc.tf")).toBe(true);
|
|
46
|
+
expect(re.test("main.tfvars")).toBe(false);
|
|
47
|
+
expect(re.test("src/app.ts")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("matches a directory subtree with **", () => {
|
|
51
|
+
const re = globToRegex("modules/**");
|
|
52
|
+
expect(re.test("modules/net/vpc.tf")).toBe(true);
|
|
53
|
+
expect(re.test("modules/x")).toBe(true);
|
|
54
|
+
expect(re.test("other/x.tf")).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("single * stays within a path segment", () => {
|
|
58
|
+
const re = globToRegex("*.tf");
|
|
59
|
+
expect(re.test("main.tf")).toBe(true);
|
|
60
|
+
expect(re.test("modules/main.tf")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Adversarial coverage for the hand-rolled glob compiler — it backs the path
|
|
65
|
+
// allow-list and the protected-paths deny-list, so a regex-injection or a
|
|
66
|
+
// segment-crossing bypass here would defeat those guardrails.
|
|
67
|
+
describe("globToRegex (adversarial / injection)", () => {
|
|
68
|
+
it("escapes regex metacharacters in the glob (no injection from the pattern)", () => {
|
|
69
|
+
// `.` is a literal dot, not 'any char'
|
|
70
|
+
expect(globToRegex("a.tf").test("axtf")).toBe(false);
|
|
71
|
+
expect(globToRegex("a.tf").test("a.tf")).toBe(true);
|
|
72
|
+
// anchors/alternation/group chars from the pattern are treated literally
|
|
73
|
+
const re = globToRegex("a(b|c)$.tf");
|
|
74
|
+
expect(re.test("a(b|c)$.tf")).toBe(true);
|
|
75
|
+
expect(re.test("ab.tf")).toBe(false);
|
|
76
|
+
// `+` is a literal plus, not a regex quantifier
|
|
77
|
+
expect(globToRegex("a+.tf").test("a+.tf")).toBe(true);
|
|
78
|
+
expect(globToRegex("a+.tf").test("aaaa.tf")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is fully anchored — no partial-match bypass", () => {
|
|
82
|
+
const re = globToRegex("*.tf");
|
|
83
|
+
expect(re.test("main.tf.bak")).toBe(false);
|
|
84
|
+
expect(re.test("evil/main.tf")).toBe(false);
|
|
85
|
+
// a separator anywhere defeats a single-segment glob, so a slash-bearing
|
|
86
|
+
// suffix can't be smuggled past the anchored pattern
|
|
87
|
+
expect(re.test("main.tf/../etc/passwd")).toBe(false);
|
|
88
|
+
expect(re.test("main.tf\n/etc/passwd")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("`*`/`?` never cross a path separator, blocking `../` traversal", () => {
|
|
92
|
+
// single-segment globs reject any path containing a separator…
|
|
93
|
+
expect(isPathAllowed("../secret.tf", ["*.tf"])).toBe(false);
|
|
94
|
+
expect(isPathAllowed("..\\secret.tf", ["*.tf"])).toBe(false); // windows sep normalized then rejected
|
|
95
|
+
expect(isPathAllowed("a/b.tf", ["*.tf"])).toBe(false);
|
|
96
|
+
expect(globToRegex("?.tf").test("/.tf")).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("`**` deliberately spans separators (documented power of the deny-list)", () => {
|
|
100
|
+
// protected-paths rely on this: `prod/**` must catch nested files. The
|
|
101
|
+
// safety of the allow-list against `..` does NOT come from `**` globs — it
|
|
102
|
+
// comes from changed-file paths being git-relative (no `..`), which is
|
|
103
|
+
// enforced upstream in changedFilesSinceRunStart.
|
|
104
|
+
expect(isPathAllowed("prod/db/main.tf", ["prod/**"])).toBe(true);
|
|
105
|
+
expect(isPathAllowed("prod/a/b/c/secret", ["prod/**"])).toBe(true);
|
|
106
|
+
expect(isPathAllowed("staging/main.tf", ["prod/**"])).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("isPathAllowed (default Terraform allow-list)", () => {
|
|
111
|
+
const globs = [...DEFAULT_ALLOWED_PATHS];
|
|
112
|
+
|
|
113
|
+
it("allows .tf and .tfvars at any depth", () => {
|
|
114
|
+
expect(isPathAllowed("main.tf", globs)).toBe(true);
|
|
115
|
+
expect(isPathAllowed("modules/net/vpc.tf", globs)).toBe(true);
|
|
116
|
+
expect(isPathAllowed("envs/prod.tfvars", globs)).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rejects anything that isn't Terraform", () => {
|
|
120
|
+
expect(isPathAllowed(".github/workflows/ci.yml", globs)).toBe(false);
|
|
121
|
+
expect(isPathAllowed("src/index.ts", globs)).toBe(false);
|
|
122
|
+
expect(isPathAllowed("README.md", globs)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("normalizes windows separators and leading ./", () => {
|
|
126
|
+
expect(isPathAllowed("modules\\net\\vpc.tf", globs)).toBe(true);
|
|
127
|
+
expect(isPathAllowed("./main.tf", globs)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("PR-cap guardrail is scoped to the Terraform-write modes", () => {
|
|
132
|
+
// assertUnderPrCap / recordRemediationPrOpened only read toolState + payload
|
|
133
|
+
// (no git / no I/O), so a minimal cast context exercises the mode gate.
|
|
134
|
+
const ctx = (selectedMode: string | undefined, opened: number, maxPrs = 1) =>
|
|
135
|
+
({
|
|
136
|
+
toolState: { selectedMode, remediationPrsOpened: opened },
|
|
137
|
+
payload: { maxPrs },
|
|
138
|
+
}) as unknown as ToolContext;
|
|
139
|
+
|
|
140
|
+
it("throws at the cap for both Remediate and GenerateTerraform", () => {
|
|
141
|
+
expect(() => assertUnderPrCap(ctx(REMEDIATE_MODE, 1))).toThrow(/PR limit reached/);
|
|
142
|
+
expect(() => assertUnderPrCap(ctx(GENERATE_MODE, 1))).toThrow(/PR limit reached/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("allows opening up to the cap", () => {
|
|
146
|
+
expect(() => assertUnderPrCap(ctx(GENERATE_MODE, 0))).not.toThrow();
|
|
147
|
+
expect(() => assertUnderPrCap(ctx(REMEDIATE_MODE, 0))).not.toThrow();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("never engages for non-guarded modes (Build/Review/etc.), even over cap", () => {
|
|
151
|
+
expect(() => assertUnderPrCap(ctx("Build", 5))).not.toThrow();
|
|
152
|
+
expect(() => assertUnderPrCap(ctx(undefined, 5))).not.toThrow();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("only counts PRs for guarded modes", () => {
|
|
156
|
+
const guarded = ctx(GENERATE_MODE, 0);
|
|
157
|
+
recordRemediationPrOpened(guarded);
|
|
158
|
+
expect(guarded.toolState.remediationPrsOpened).toBe(1);
|
|
159
|
+
|
|
160
|
+
const unguarded = ctx("Build", 0);
|
|
161
|
+
recordRemediationPrOpened(unguarded);
|
|
162
|
+
expect(unguarded.toolState.remediationPrsOpened).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("destroy-block guardrail (§2.5 — never delete/replace a stateful resource)", () => {
|
|
167
|
+
// assertNoBlockedDestroy reads only toolState.plannedDestroy + payload.allowReplace.
|
|
168
|
+
const ctx = (
|
|
169
|
+
selectedMode: string | undefined,
|
|
170
|
+
plannedDestroy: ToolContext["toolState"]["plannedDestroy"],
|
|
171
|
+
allowReplace?: string[],
|
|
172
|
+
) =>
|
|
173
|
+
({
|
|
174
|
+
toolState: { selectedMode, plannedDestroy },
|
|
175
|
+
payload: { allowReplace },
|
|
176
|
+
}) as unknown as ToolContext;
|
|
177
|
+
|
|
178
|
+
const statefulDestroy = {
|
|
179
|
+
stateful: [{ address: "aws_db_instance.main", action: "delete", type: "aws_db_instance" }],
|
|
180
|
+
ephemeral: [],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
it("blocks a push that would destroy/replace a stateful resource", () => {
|
|
184
|
+
expect(() => assertNoBlockedDestroy(ctx(REMEDIATE_MODE, statefulDestroy))).toThrow(
|
|
185
|
+
/DESTROY or REPLACE 1 stateful/,
|
|
186
|
+
);
|
|
187
|
+
expect(() => assertNoBlockedDestroy(ctx(GENERATE_MODE, statefulDestroy))).toThrow(
|
|
188
|
+
/aws_db_instance\.main/,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("allows the destroy when the operator opted in via allow_replace (address, glob, or *)", () => {
|
|
193
|
+
expect(() =>
|
|
194
|
+
assertNoBlockedDestroy(ctx(REMEDIATE_MODE, statefulDestroy, ["aws_db_instance.main"])),
|
|
195
|
+
).not.toThrow();
|
|
196
|
+
expect(() =>
|
|
197
|
+
assertNoBlockedDestroy(ctx(REMEDIATE_MODE, statefulDestroy, ["aws_db_instance.*"])),
|
|
198
|
+
).not.toThrow();
|
|
199
|
+
expect(() => assertNoBlockedDestroy(ctx(REMEDIATE_MODE, statefulDestroy, ["*"]))).not.toThrow();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("never engages for ephemeral-only destroys (recreatable resources)", () => {
|
|
203
|
+
const ephemeralOnly = {
|
|
204
|
+
stateful: [],
|
|
205
|
+
ephemeral: [{ address: "aws_instance.web", action: "replace", type: "aws_instance" }],
|
|
206
|
+
};
|
|
207
|
+
expect(() => assertNoBlockedDestroy(ctx(REMEDIATE_MODE, ephemeralOnly))).not.toThrow();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("no-ops when no plan ran, or outside a guarded mode", () => {
|
|
211
|
+
expect(() => assertNoBlockedDestroy(ctx(REMEDIATE_MODE, undefined))).not.toThrow();
|
|
212
|
+
expect(() => assertNoBlockedDestroy(ctx("Build", statefulDestroy))).not.toThrow();
|
|
213
|
+
expect(() => assertNoBlockedDestroy(ctx(undefined, statefulDestroy))).not.toThrow();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("protected-paths guardrail (§2.7) — gating", () => {
|
|
218
|
+
// enforceProtectedPaths reaches git only AFTER the mode + empty-list gates,
|
|
219
|
+
// so these no-op cases exercise the gate without any git I/O.
|
|
220
|
+
const ctx = (selectedMode: string | undefined, protectedPaths?: string[]) =>
|
|
221
|
+
({
|
|
222
|
+
toolState: { selectedMode },
|
|
223
|
+
payload: { protectedPaths },
|
|
224
|
+
}) as unknown as ToolContext;
|
|
225
|
+
|
|
226
|
+
it("no-ops outside a guarded mode (even with protected paths set)", () => {
|
|
227
|
+
expect(() => enforceProtectedPaths(ctx("Build", ["prod/**"]))).not.toThrow();
|
|
228
|
+
expect(() => enforceProtectedPaths(ctx(undefined, ["prod/**"]))).not.toThrow();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("no-ops in a guarded mode when no protected paths are configured", () => {
|
|
232
|
+
expect(() => enforceProtectedPaths(ctx(REMEDIATE_MODE, undefined))).not.toThrow();
|
|
233
|
+
expect(() => enforceProtectedPaths(ctx(REMEDIATE_MODE, []))).not.toThrow();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("a protected glob matches via the same engine as allowed_paths (inverse semantics)", () => {
|
|
237
|
+
// matching a protected glob means BLOCKED — verify the matcher the guardrail
|
|
238
|
+
// uses (isPathAllowed) behaves as expected for the protected-list direction.
|
|
239
|
+
expect(isPathAllowed("prod/main.tf", ["prod/**"])).toBe(true);
|
|
240
|
+
expect(isPathAllowed("dev/main.tf", ["prod/**"])).toBe(false);
|
|
241
|
+
expect(isPathAllowed("modules/db/main.tf", ["**/db/**"])).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("scanDiffForSecrets (§2.8)", () => {
|
|
246
|
+
const diff = (...lines: string[]) => lines.join("\n");
|
|
247
|
+
|
|
248
|
+
it("flags an inlined AWS access key id on an added line, with file:line", () => {
|
|
249
|
+
const d = diff(
|
|
250
|
+
"diff --git a/main.tf b/main.tf",
|
|
251
|
+
"--- a/main.tf",
|
|
252
|
+
"+++ b/main.tf",
|
|
253
|
+
"@@ -1,2 +1,3 @@",
|
|
254
|
+
' resource "aws_iam_user" "x" {',
|
|
255
|
+
'+ access_key = "AKIAIOSFODNN7EXAMPLE"',
|
|
256
|
+
" }",
|
|
257
|
+
);
|
|
258
|
+
const hits = scanDiffForSecrets(d);
|
|
259
|
+
expect(hits).toHaveLength(2); // AKIA value pattern + sensitive-assignment
|
|
260
|
+
expect(hits.some((h) => h.rule === "aws-access-key-id")).toBe(true);
|
|
261
|
+
expect(hits[0]?.file).toBe("main.tf");
|
|
262
|
+
expect(hits[0]?.line).toBe(2); // second new-side line in the hunk
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("flags a hardcoded password literal but NOT a variable reference", () => {
|
|
266
|
+
const literal = diff("+++ b/x.tf", "@@ -0,0 +1 @@", '+ password = "hunter2"');
|
|
267
|
+
expect(scanDiffForSecrets(literal).map((h) => h.rule)).toContain("hardcoded-secret-assignment");
|
|
268
|
+
|
|
269
|
+
const ref = diff("+++ b/x.tf", "@@ -0,0 +1 @@", "+ password = var.db_password");
|
|
270
|
+
expect(scanDiffForSecrets(ref)).toEqual([]);
|
|
271
|
+
|
|
272
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal HCL interpolation fixture, not a JS template
|
|
273
|
+
const interp = diff("+++ b/x.tf", "@@ -0,0 +1 @@", '+ password = "${var.db_password}"');
|
|
274
|
+
expect(scanDiffForSecrets(interp)).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("flags a PEM private-key header", () => {
|
|
278
|
+
const d = diff("+++ b/key.tf", "@@ -0,0 +1 @@", '+ key = "-----BEGIN RSA PRIVATE KEY-----"');
|
|
279
|
+
expect(scanDiffForSecrets(d).some((h) => h.rule === "pem-private-key")).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("ignores secrets on removed/context lines (only ADDED lines count)", () => {
|
|
283
|
+
const d = diff(
|
|
284
|
+
"+++ b/main.tf",
|
|
285
|
+
"@@ -1,2 +1,1 @@",
|
|
286
|
+
'- password = "hunter2"', // removed — pre-existing, not this run's doing
|
|
287
|
+
' resource "x" "y" {}',
|
|
288
|
+
);
|
|
289
|
+
expect(scanDiffForSecrets(d)).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("returns nothing for a clean diff", () => {
|
|
293
|
+
const d = diff("+++ b/main.tf", "@@ -0,0 +1 @@", "+ bucket = var.bucket_name");
|
|
294
|
+
expect(scanDiffForSecrets(d)).toEqual([]);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// --- git-backed guardrails (mocked `$` + spawnSync) --------------------------
|
|
299
|
+
|
|
300
|
+
/** `$` stub that resolves the run-start sha and returns canned git output. */
|
|
301
|
+
function gitStub(out: { diffNames?: string; diff?: string; failRevParse?: boolean }) {
|
|
302
|
+
shellMock.mockImplementation((cmd: string, args: string[]) => {
|
|
303
|
+
if (cmd !== "git") throw new Error(`unexpected command: ${cmd}`);
|
|
304
|
+
if (args[0] === "rev-parse") {
|
|
305
|
+
if (out.failRevParse) throw new Error("fatal: unknown revision");
|
|
306
|
+
return "base-sha";
|
|
307
|
+
}
|
|
308
|
+
if (args[0] === "diff" && args[1] === "--name-only") return out.diffNames ?? "";
|
|
309
|
+
if (args[0] === "diff") return out.diff ?? "";
|
|
310
|
+
throw new Error(`unexpected git args: ${args.join(" ")}`);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function gitCtx(over: {
|
|
315
|
+
selectedMode?: string;
|
|
316
|
+
payload?: Record<string, unknown>;
|
|
317
|
+
tmpdir?: string;
|
|
318
|
+
}): ToolContext {
|
|
319
|
+
return {
|
|
320
|
+
toolState: {
|
|
321
|
+
selectedMode: over.selectedMode ?? REMEDIATE_MODE,
|
|
322
|
+
initialHead: { kind: "branch", name: "main" },
|
|
323
|
+
},
|
|
324
|
+
payload: { ...(over.payload ?? {}) },
|
|
325
|
+
tmpdir: over.tmpdir ?? "",
|
|
326
|
+
} as unknown as ToolContext;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
beforeEach(() => {
|
|
330
|
+
shellMock.mockReset();
|
|
331
|
+
spawnSyncMock.mockReset();
|
|
332
|
+
spawnSyncMock.mockImplementation(() => ({
|
|
333
|
+
error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }),
|
|
334
|
+
status: null,
|
|
335
|
+
stdout: "",
|
|
336
|
+
stderr: "",
|
|
337
|
+
}));
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("resolveAllowedPaths", () => {
|
|
341
|
+
it("defaults to Terraform-only globs", () => {
|
|
342
|
+
expect(resolveAllowedPaths(gitCtx({}))).toEqual([...DEFAULT_ALLOWED_PATHS]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("prefers the operator-configured allow-list", () => {
|
|
346
|
+
const ctx = gitCtx({ payload: { allowedPaths: ["infra/**"] } });
|
|
347
|
+
expect(resolveAllowedPaths(ctx)).toEqual(["infra/**"]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("treats an EMPTY configured list as unset (falls back to the default)", () => {
|
|
351
|
+
expect(resolveAllowedPaths(gitCtx({ payload: { allowedPaths: [] } }))).toEqual([
|
|
352
|
+
...DEFAULT_ALLOWED_PATHS,
|
|
353
|
+
]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("adds the Terratest scaffold paths only when the terratest input is on (§28)", () => {
|
|
357
|
+
const ctx = gitCtx({ payload: { terratest: true } });
|
|
358
|
+
expect(resolveAllowedPaths(ctx)).toEqual([
|
|
359
|
+
...DEFAULT_ALLOWED_PATHS,
|
|
360
|
+
...TERRATEST_ALLOWED_PATHS,
|
|
361
|
+
]);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("enforceRemediationPaths (path allow-list at push time)", () => {
|
|
366
|
+
it("passes when every changed file is Terraform", () => {
|
|
367
|
+
gitStub({ diffNames: "main.tf\nmodules/net/vpc.tf\nenvs/prod.tfvars\n" });
|
|
368
|
+
expect(() => enforceRemediationPaths(gitCtx({}))).not.toThrow();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("blocks the push when a non-Terraform file changed, listing the violations", () => {
|
|
372
|
+
gitStub({ diffNames: "main.tf\n.github/workflows/ci.yml\n" });
|
|
373
|
+
expect(() => enforceRemediationPaths(gitCtx({}))).toThrow(
|
|
374
|
+
/push blocked.*\.github\/workflows\/ci\.yml/s,
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("fails closed when the run-start commit can't be established", () => {
|
|
379
|
+
gitStub({ failRevParse: true });
|
|
380
|
+
expect(() => enforceRemediationPaths(gitCtx({}))).toThrow(
|
|
381
|
+
/could not establish the run-start commit/,
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("never engages outside the Terraform-write modes", () => {
|
|
386
|
+
gitStub({ diffNames: "src/app.ts\n" });
|
|
387
|
+
expect(() => enforceRemediationPaths(gitCtx({ selectedMode: "Build" }))).not.toThrow();
|
|
388
|
+
expect(shellMock).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe("enforceProtectedPaths (deny-list at push time)", () => {
|
|
393
|
+
it("blocks a push that modified a protected file", () => {
|
|
394
|
+
gitStub({ diffNames: "prod/db/main.tf\nstaging/main.tf\n" });
|
|
395
|
+
const ctx = gitCtx({ payload: { protectedPaths: ["prod/**"] } });
|
|
396
|
+
expect(() => enforceProtectedPaths(ctx)).toThrow(/push blocked.*prod\/db\/main\.tf/s);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("passes when no change matched a protected glob", () => {
|
|
400
|
+
gitStub({ diffNames: "staging/main.tf\n" });
|
|
401
|
+
const ctx = gitCtx({ payload: { protectedPaths: ["prod/**"] } });
|
|
402
|
+
expect(() => enforceProtectedPaths(ctx)).not.toThrow();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("fails closed on a missing run-start baseline", () => {
|
|
406
|
+
gitStub({ failRevParse: true });
|
|
407
|
+
const ctx = gitCtx({ payload: { protectedPaths: ["prod/**"] } });
|
|
408
|
+
expect(() => enforceProtectedPaths(ctx)).toThrow(/could not establish the run-start commit/);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("assertNoSecretsInDiff (§2.8 at push time)", () => {
|
|
413
|
+
const cleanDiff = ["+++ b/main.tf", "@@ -0,0 +1 @@", "+ bucket = var.bucket_name"].join("\n");
|
|
414
|
+
const leakyDiff = ["+++ b/main.tf", "@@ -0,0 +1 @@", '+ password = "hunter2"'].join("\n");
|
|
415
|
+
|
|
416
|
+
it("passes on a clean diff", () => {
|
|
417
|
+
gitStub({ diff: cleanDiff });
|
|
418
|
+
expect(() => assertNoSecretsInDiff(gitCtx({}))).not.toThrow();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("blocks a push whose diff inlines a secret, with file:line and rule", () => {
|
|
422
|
+
gitStub({ diff: leakyDiff });
|
|
423
|
+
expect(() => assertNoSecretsInDiff(gitCtx({}))).toThrow(
|
|
424
|
+
/push blocked.*main\.tf:1 \(hardcoded-secret-assignment\)/s,
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("fails closed when the baseline is missing and no-ops outside guarded modes", () => {
|
|
429
|
+
gitStub({ failRevParse: true });
|
|
430
|
+
expect(() => assertNoSecretsInDiff(gitCtx({}))).toThrow(/could not establish/);
|
|
431
|
+
expect(() => assertNoSecretsInDiff(gitCtx({ selectedMode: "Review" }))).not.toThrow();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("degrades to the built-in scanner when gitleaks is requested but absent", () => {
|
|
435
|
+
gitStub({ diff: cleanDiff });
|
|
436
|
+
// spawnSync default stub is ENOENT — gitleaks "not installed".
|
|
437
|
+
const ctx = gitCtx({ payload: { gitleaks: true } });
|
|
438
|
+
expect(() => assertNoSecretsInDiff(ctx)).not.toThrow();
|
|
439
|
+
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
440
|
+
"gitleaks",
|
|
441
|
+
expect.arrayContaining(["detect"]),
|
|
442
|
+
expect.anything(),
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("merges gitleaks hits on top of the built-in baseline", () => {
|
|
447
|
+
gitStub({ diff: cleanDiff });
|
|
448
|
+
const dir = mkdtempSync(join(tmpdir(), "terramend-gitleaks-"));
|
|
449
|
+
try {
|
|
450
|
+
spawnSyncMock.mockImplementation((cmd: unknown, args: unknown) => {
|
|
451
|
+
if (cmd !== "gitleaks") throw new Error(`unexpected spawn: ${String(cmd)}`);
|
|
452
|
+
const argv = args as string[];
|
|
453
|
+
const reportPath = argv[argv.indexOf("--report-path") + 1] ?? "";
|
|
454
|
+
writeFileSync(
|
|
455
|
+
reportPath,
|
|
456
|
+
JSON.stringify([{ RuleID: "aws-access-token", File: "main.tf", StartLine: 3 }]),
|
|
457
|
+
);
|
|
458
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
459
|
+
});
|
|
460
|
+
const ctx = gitCtx({ payload: { gitleaks: true }, tmpdir: dir });
|
|
461
|
+
expect(() => assertNoSecretsInDiff(ctx)).toThrow(/main\.tf:3 \(gitleaks:aws-access-token\)/);
|
|
462
|
+
} finally {
|
|
463
|
+
rmSync(dir, { recursive: true, force: true });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("treats a gitleaks run with no report file as a clean scan", () => {
|
|
468
|
+
gitStub({ diff: cleanDiff });
|
|
469
|
+
spawnSyncMock.mockImplementation(() => ({ status: 0, stdout: "", stderr: "" }));
|
|
470
|
+
const dir = mkdtempSync(join(tmpdir(), "terramend-gitleaks-"));
|
|
471
|
+
try {
|
|
472
|
+
const ctx = gitCtx({ payload: { gitleaks: true }, tmpdir: dir });
|
|
473
|
+
expect(() => assertNoSecretsInDiff(ctx)).not.toThrow();
|
|
474
|
+
} finally {
|
|
475
|
+
rmSync(dir, { recursive: true, force: true });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("resolveMaxPrs default (via assertUnderPrCap)", () => {
|
|
481
|
+
it("caps at one PR per run when max_prs is not configured", () => {
|
|
482
|
+
const ctx = {
|
|
483
|
+
toolState: { selectedMode: REMEDIATE_MODE, remediationPrsOpened: 1 },
|
|
484
|
+
payload: {},
|
|
485
|
+
} as unknown as ToolContext;
|
|
486
|
+
expect(() => assertUnderPrCap(ctx)).toThrow(/at most 1 PR/);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// --- mutation-hardening tests ------------------------------------------------
|
|
491
|
+
// Added after a Stryker run showed these behaviors could regress silently: the
|
|
492
|
+
// earlier tests exercised the right functions but with inputs that couldn't
|
|
493
|
+
// distinguish redundant-looking arms (e.g. `*` vs the glob fallback) or never
|
|
494
|
+
// observed the side channel (log output, subprocess options, line numbers).
|
|
495
|
+
|
|
496
|
+
describe("globToRegex `?` semantics", () => {
|
|
497
|
+
it("`?` consumes exactly one non-separator character", () => {
|
|
498
|
+
expect(globToRegex("?.tf").test("a.tf")).toBe(true);
|
|
499
|
+
expect(globToRegex("?.tf").test(".tf")).toBe(false);
|
|
500
|
+
expect(globToRegex("?.tf").test("ab.tf")).toBe(false);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe("destroy-block allowlist arms (§2.5)", () => {
|
|
505
|
+
const ctx = (plannedDestroy: ToolContext["toolState"]["plannedDestroy"], allow?: string[]) =>
|
|
506
|
+
({
|
|
507
|
+
toolState: { selectedMode: REMEDIATE_MODE, plannedDestroy },
|
|
508
|
+
payload: { allowReplace: allow },
|
|
509
|
+
}) as unknown as ToolContext;
|
|
510
|
+
|
|
511
|
+
it("honors the `all` keyword", () => {
|
|
512
|
+
const planned = {
|
|
513
|
+
stateful: [{ address: "aws_db_instance.main", action: "delete", type: "aws_db_instance" }],
|
|
514
|
+
ephemeral: [],
|
|
515
|
+
};
|
|
516
|
+
expect(() => assertNoBlockedDestroy(ctx(planned, ["all"]))).not.toThrow();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("`*` allows an address the glob engine cannot match (slash inside an index key)", () => {
|
|
520
|
+
// globToRegex("*") compiles to [^/]* — it does NOT match an address whose
|
|
521
|
+
// for_each key contains a slash. only the literal `*` arm covers it, so
|
|
522
|
+
// this input distinguishes that arm from the glob fallback.
|
|
523
|
+
const planned = {
|
|
524
|
+
stateful: [
|
|
525
|
+
{ address: 'aws_s3_bucket.b["logs/prod"]', action: "delete", type: "aws_s3_bucket" },
|
|
526
|
+
],
|
|
527
|
+
ephemeral: [],
|
|
528
|
+
};
|
|
529
|
+
expect(() => assertNoBlockedDestroy(ctx(planned, ["*"]))).not.toThrow();
|
|
530
|
+
expect(() => assertNoBlockedDestroy(ctx(planned, ["aws_db_instance.*"]))).toThrow(
|
|
531
|
+
/DESTROY or REPLACE/,
|
|
532
|
+
);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("a non-matching allowlist still blocks", () => {
|
|
536
|
+
const planned = {
|
|
537
|
+
stateful: [{ address: "aws_db_instance.main", action: "delete", type: "aws_db_instance" }],
|
|
538
|
+
ephemeral: [],
|
|
539
|
+
};
|
|
540
|
+
expect(() => assertNoBlockedDestroy(ctx(planned, ["aws_s3_bucket.other"]))).toThrow(
|
|
541
|
+
/aws_db_instance\.main/,
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("the empty-stateful early return skips the gate entirely (no ok-log)", () => {
|
|
546
|
+
const infoSpy = vi.spyOn(log, "info").mockImplementation(() => {});
|
|
547
|
+
try {
|
|
548
|
+
assertNoBlockedDestroy(ctx({ stateful: [], ephemeral: [] }));
|
|
549
|
+
expect(infoSpy).not.toHaveBeenCalled();
|
|
550
|
+
} finally {
|
|
551
|
+
infoSpy.mockRestore();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
describe("run-start baseline resolution (branch vs detached head)", () => {
|
|
557
|
+
it("resolves a branch head via its name and pins {log: false} on both git calls", () => {
|
|
558
|
+
gitStub({ diffNames: "main.tf\n" });
|
|
559
|
+
enforceRemediationPaths(gitCtx({}));
|
|
560
|
+
expect(shellMock).toHaveBeenCalledWith("git", ["rev-parse", "main"], { log: false });
|
|
561
|
+
expect(shellMock).toHaveBeenCalledWith("git", ["diff", "--name-only", "base-sha", "HEAD"], {
|
|
562
|
+
log: false,
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("resolves a detached head via its sha", () => {
|
|
567
|
+
gitStub({ diffNames: "main.tf\n" });
|
|
568
|
+
const ctx = {
|
|
569
|
+
toolState: {
|
|
570
|
+
selectedMode: REMEDIATE_MODE,
|
|
571
|
+
initialHead: { kind: "detached", sha: "abc123" },
|
|
572
|
+
},
|
|
573
|
+
payload: {},
|
|
574
|
+
tmpdir: "",
|
|
575
|
+
} as unknown as ToolContext;
|
|
576
|
+
enforceRemediationPaths(ctx);
|
|
577
|
+
expect(shellMock).toHaveBeenCalledWith("git", ["rev-parse", "abc123"], { log: false });
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("trims whitespace-padded filenames from git output before matching", () => {
|
|
581
|
+
gitStub({ diffNames: " main.tf \n\n modules/net/vpc.tf\n" });
|
|
582
|
+
expect(() => enforceRemediationPaths(gitCtx({}))).not.toThrow();
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe("scanDiffForSecrets line attribution (§2.8)", () => {
|
|
587
|
+
it("tracks multi-digit hunk starts and advances only on added/context lines", () => {
|
|
588
|
+
const d = [
|
|
589
|
+
"+++ b/main.tf",
|
|
590
|
+
"@@ -50,4 +100,5 @@",
|
|
591
|
+
" context line",
|
|
592
|
+
'-password = "old"',
|
|
593
|
+
'+password = "new1"',
|
|
594
|
+
" another context",
|
|
595
|
+
'+api_key = "literalvalue"',
|
|
596
|
+
].join("\n");
|
|
597
|
+
const hits = scanDiffForSecrets(d);
|
|
598
|
+
expect(hits.map((h) => ({ file: h.file, line: h.line }))).toEqual([
|
|
599
|
+
{ file: "main.tf", line: 101 },
|
|
600
|
+
{ file: "main.tf", line: 103 },
|
|
601
|
+
]);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("attributes a /dev/null new-side header as (deleted)", () => {
|
|
605
|
+
// git never emits added lines under /dev/null, but the parser is pure and
|
|
606
|
+
// fed external text — pin the labeling rather than leave it dead.
|
|
607
|
+
const d = ["+++ /dev/null", "@@ -1,1 +0,0 @@", '+password = "x"'].join("\n");
|
|
608
|
+
const hits = scanDiffForSecrets(d);
|
|
609
|
+
expect(hits[0]?.file).toBe("(deleted)");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("strips CRLF from header and content lines", () => {
|
|
613
|
+
const d = ["+++ b/main.tf\r", "@@ -0,0 +1 @@\r", '+password = "x"\r'].join("\n");
|
|
614
|
+
const hits = scanDiffForSecrets(d);
|
|
615
|
+
expect(hits[0]?.file).toBe("main.tf");
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
describe("scanWithGitleaks subprocess contract", () => {
|
|
620
|
+
it("does not spawn gitleaks at all when the input is off", () => {
|
|
621
|
+
gitStub({ diff: ["+++ b/main.tf", "@@ -0,0 +1 @@", "+ bucket = var.b"].join("\n") });
|
|
622
|
+
assertNoSecretsInDiff(gitCtx({}));
|
|
623
|
+
expect(spawnSyncMock).not.toHaveBeenCalled();
|
|
624
|
+
// the diff itself is read with logging suppressed (restricted surface)
|
|
625
|
+
expect(shellMock).toHaveBeenCalledWith("git", ["diff", "base-sha", "HEAD"], { log: false });
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("runs from payload.cwd (falling back to process.cwd()) with the capped buffer", () => {
|
|
629
|
+
gitStub({ diff: ["+++ b/main.tf", "@@ -0,0 +1 @@", "+ bucket = var.b"].join("\n") });
|
|
630
|
+
const warnSpy = vi.spyOn(log, "warning").mockImplementation(() => {});
|
|
631
|
+
try {
|
|
632
|
+
assertNoSecretsInDiff(gitCtx({ payload: { gitleaks: true } }));
|
|
633
|
+
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
634
|
+
"gitleaks",
|
|
635
|
+
expect.arrayContaining(["detect"]),
|
|
636
|
+
expect.objectContaining({
|
|
637
|
+
cwd: process.cwd(),
|
|
638
|
+
encoding: "utf-8",
|
|
639
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
// ENOENT (default stub) → the "not installed" degrade message
|
|
643
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not installed"));
|
|
644
|
+
|
|
645
|
+
assertNoSecretsInDiff(gitCtx({ payload: { gitleaks: true, cwd: "/custom/dir" } }));
|
|
646
|
+
expect(spawnSyncMock).toHaveBeenLastCalledWith(
|
|
647
|
+
"gitleaks",
|
|
648
|
+
expect.anything(),
|
|
649
|
+
expect.objectContaining({ cwd: "/custom/dir" }),
|
|
650
|
+
);
|
|
651
|
+
} finally {
|
|
652
|
+
warnSpy.mockRestore();
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("distinguishes a non-ENOENT spawn failure in the degrade message", () => {
|
|
657
|
+
gitStub({ diff: ["+++ b/main.tf", "@@ -0,0 +1 @@", "+ bucket = var.b"].join("\n") });
|
|
658
|
+
spawnSyncMock.mockImplementation(() => ({
|
|
659
|
+
error: new Error("EACCES: permission denied"),
|
|
660
|
+
status: null,
|
|
661
|
+
stdout: "",
|
|
662
|
+
stderr: "",
|
|
663
|
+
}));
|
|
664
|
+
const warnSpy = vi.spyOn(log, "warning").mockImplementation(() => {});
|
|
665
|
+
try {
|
|
666
|
+
assertNoSecretsInDiff(gitCtx({ payload: { gitleaks: true } }));
|
|
667
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("could not run"));
|
|
668
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("not installed"));
|
|
669
|
+
} finally {
|
|
670
|
+
warnSpy.mockRestore();
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
describe("parseGitleaksReport (§2.8 — optional gitleaks engine)", () => {
|
|
676
|
+
it("maps gitleaks findings to SecretHit with a gitleaks: rule prefix", () => {
|
|
677
|
+
const report = JSON.stringify([
|
|
678
|
+
{ RuleID: "aws-access-token", File: "main.tf", StartLine: 12, Description: "AWS" },
|
|
679
|
+
{ RuleID: "generic-api-key", File: "modules/db/main.tf", StartLine: 4 },
|
|
680
|
+
]);
|
|
681
|
+
expect(parseGitleaksReport(report)).toEqual([
|
|
682
|
+
{ file: "main.tf", line: 12, rule: "gitleaks:aws-access-token" },
|
|
683
|
+
{ file: "modules/db/main.tf", line: 4, rule: "gitleaks:generic-api-key" },
|
|
684
|
+
]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("tolerates an empty report, empty string, and malformed JSON", () => {
|
|
688
|
+
expect(parseGitleaksReport("[]")).toEqual([]);
|
|
689
|
+
expect(parseGitleaksReport("")).toEqual([]);
|
|
690
|
+
expect(parseGitleaksReport("not json")).toEqual([]);
|
|
691
|
+
expect(parseGitleaksReport(JSON.stringify({ not: "an array" }))).toEqual([]);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("defaults missing fields rather than throwing", () => {
|
|
695
|
+
expect(parseGitleaksReport(JSON.stringify([{}]))).toEqual([
|
|
696
|
+
{ file: "(unknown)", line: 0, rule: "gitleaks:secret" },
|
|
697
|
+
]);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("treats EMPTY-string fields as missing (not as a real file/rule)", () => {
|
|
701
|
+
expect(parseGitleaksReport(JSON.stringify([{ File: "", RuleID: "", StartLine: 2 }]))).toEqual([
|
|
702
|
+
{ file: "(unknown)", line: 2, rule: "gitleaks:secret" },
|
|
703
|
+
]);
|
|
704
|
+
});
|
|
705
|
+
});
|