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,527 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { CostDelta } from "#app/mcp/terraform/cost";
|
|
3
|
+
import {
|
|
4
|
+
type Autonomy,
|
|
5
|
+
type BlastTier,
|
|
6
|
+
type Concern,
|
|
7
|
+
type ConcernGroup,
|
|
8
|
+
SEVERITY_RANK,
|
|
9
|
+
type Severity,
|
|
10
|
+
} from "#app/mcp/terraform/types";
|
|
11
|
+
|
|
12
|
+
// --- grouping --------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
function groupId(file: string): string {
|
|
15
|
+
return createHash("sha1").update(`group|${file}`).digest("hex").slice(0, 12);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ruleGroupId(ruleId: string): string {
|
|
19
|
+
return createHash("sha1").update(`rulegroup|${ruleId}`).digest("hex").slice(0, 12);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function maxSeverity(cs: Concern[]): Severity {
|
|
23
|
+
return cs.reduce<Severity>(
|
|
24
|
+
(max, c) => (SEVERITY_RANK[c.severity] > SEVERITY_RANK[max] ? c.severity : max),
|
|
25
|
+
"info",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sortGroups(groups: ConcernGroup[]): ConcernGroup[] {
|
|
30
|
+
return groups.sort((a, b) => {
|
|
31
|
+
const sev = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
|
|
32
|
+
if (sev !== 0) return sev;
|
|
33
|
+
return a.id.localeCompare(b.id);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** group concerns by file into scoped units, sorted by max severity. */
|
|
38
|
+
export function groupConcerns(concerns: Concern[]): ConcernGroup[] {
|
|
39
|
+
const byFile = new Map<string, Concern[]>();
|
|
40
|
+
for (const c of concerns) {
|
|
41
|
+
const arr = byFile.get(c.location.file) ?? [];
|
|
42
|
+
arr.push(c);
|
|
43
|
+
byFile.set(c.location.file, arr);
|
|
44
|
+
}
|
|
45
|
+
const groups: ConcernGroup[] = [];
|
|
46
|
+
for (const [file, cs] of byFile) {
|
|
47
|
+
groups.push({
|
|
48
|
+
id: groupId(file),
|
|
49
|
+
file,
|
|
50
|
+
files: [file],
|
|
51
|
+
grouping: "file",
|
|
52
|
+
severity: maxSeverity(cs),
|
|
53
|
+
concern_count: cs.length,
|
|
54
|
+
rule_ids: [...new Set(cs.map((c) => c.rule_id))].sort(),
|
|
55
|
+
concern_ids: cs.map((c) => c.id),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return sortGroups(groups);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* §3.11 — group concerns by RULE across files instead of by file. When a single
|
|
63
|
+
* rule fires in many files ("add `tags` to every resource", "enable encryption
|
|
64
|
+
* on every bucket"), fixing it as ONE coherent change is far better than N
|
|
65
|
+
* near-identical per-file PRs. Each group covers one `rule_id` and lists every
|
|
66
|
+
* `file` it spans; the branch key (`remediate/<id>`) is rule-derived and stable.
|
|
67
|
+
* Opt-in (scan `group_by: "rule"`) — by-file stays the default because it keeps
|
|
68
|
+
* each PR's blast radius smaller; by-rule suits sweeping, low-risk rules.
|
|
69
|
+
*/
|
|
70
|
+
export function groupConcernsByRule(concerns: Concern[]): ConcernGroup[] {
|
|
71
|
+
const byRule = new Map<string, Concern[]>();
|
|
72
|
+
for (const c of concerns) {
|
|
73
|
+
const arr = byRule.get(c.rule_id) ?? [];
|
|
74
|
+
arr.push(c);
|
|
75
|
+
byRule.set(c.rule_id, arr);
|
|
76
|
+
}
|
|
77
|
+
const groups: ConcernGroup[] = [];
|
|
78
|
+
for (const [ruleId, cs] of byRule) {
|
|
79
|
+
const files = [...new Set(cs.map((c) => c.location.file))].sort();
|
|
80
|
+
groups.push({
|
|
81
|
+
id: ruleGroupId(ruleId),
|
|
82
|
+
file: files.length === 1 ? files[0]! : `${files.length} files`,
|
|
83
|
+
files,
|
|
84
|
+
grouping: "rule",
|
|
85
|
+
severity: maxSeverity(cs),
|
|
86
|
+
concern_count: cs.length,
|
|
87
|
+
rule_ids: [ruleId],
|
|
88
|
+
concern_ids: cs.map((c) => c.id),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return sortGroups(groups);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* §3.9 — annotate each group with an autonomy decision. Works for BOTH grouping
|
|
96
|
+
* modes: it resolves a group's concerns by `concern_ids` membership (not by
|
|
97
|
+
* `file`, which is just a label for by-rule groups), so the severity/category
|
|
98
|
+
* policy applies identically. Blast radius isn't known until terraform_plan
|
|
99
|
+
* runs, so it can only escalate a group later (the plan tool + prompt apply the
|
|
100
|
+
* `high`-blast override); at scan time autonomy is severity/category-driven.
|
|
101
|
+
*/
|
|
102
|
+
export function annotateGroups(
|
|
103
|
+
groups: ConcernGroup[],
|
|
104
|
+
all: Concern[],
|
|
105
|
+
threshold: Severity,
|
|
106
|
+
): ConcernGroup[] {
|
|
107
|
+
const byId = new Map(all.map((c) => [c.id, c]));
|
|
108
|
+
return groups.map((g) => {
|
|
109
|
+
const groupConcerns = g.concern_ids.map((id) => byId.get(id)).filter((c): c is Concern => !!c);
|
|
110
|
+
const decision = classifyAutonomy(groupConcerns, threshold);
|
|
111
|
+
return { ...g, autonomy: decision.autonomy, autonomy_reasons: decision.reasons };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- §3.10 atomic vs batched PRs -------------------------------------------
|
|
116
|
+
|
|
117
|
+
export interface BatchPlan {
|
|
118
|
+
/** group ids safe to combine into ONE low-risk PR (`remediate/batch-<hash>`). */
|
|
119
|
+
batchable: string[];
|
|
120
|
+
/** group ids that must each get their own PR (security / higher severity /
|
|
121
|
+
* needs-human / large blast). */
|
|
122
|
+
isolated: string[];
|
|
123
|
+
/** deterministic branch name for the batch (stable for the same member set). */
|
|
124
|
+
batch_branch: string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** a group is safe to batch when it's low-risk: severity `low`/`info` AND its
|
|
128
|
+
* autonomy decision is `auto` (no escalating security finding, no high blast). */
|
|
129
|
+
function isBatchable(g: ConcernGroup): boolean {
|
|
130
|
+
const lowRisk = g.severity === "low" || g.severity === "info";
|
|
131
|
+
return lowRisk && g.autonomy !== "needs-human";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* §3.10 — split annotated groups into a single low-risk BATCH (merged into one
|
|
136
|
+
* easy-to-review PR) and the riskier groups that each stay ISOLATED in their own
|
|
137
|
+
* PR (so they can be reviewed/reverted independently). The batch branch name
|
|
138
|
+
* hashes the sorted member ids, so re-runs over the same set reuse the branch
|
|
139
|
+
* (idempotent). Returns `batch_branch: null` when fewer than two groups are
|
|
140
|
+
* batchable (one group is just a normal single-group PR, not a batch).
|
|
141
|
+
*/
|
|
142
|
+
export function planBatches(groups: ConcernGroup[]): BatchPlan {
|
|
143
|
+
const batchable = groups
|
|
144
|
+
.filter(isBatchable)
|
|
145
|
+
.map((g) => g.id)
|
|
146
|
+
.sort();
|
|
147
|
+
const isolated = groups
|
|
148
|
+
.filter((g) => !isBatchable(g))
|
|
149
|
+
.map((g) => g.id)
|
|
150
|
+
.sort();
|
|
151
|
+
const batch_branch =
|
|
152
|
+
batchable.length >= 2
|
|
153
|
+
? `remediate/batch-${createHash("sha1").update(batchable.join("|")).digest("hex").slice(0, 12)}`
|
|
154
|
+
: null;
|
|
155
|
+
return { batchable, isolated, batch_branch };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- §5.17 per-finding explanation (rule documentation links) --------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve the canonical documentation URL for a concern's rule, for the PR's
|
|
162
|
+
* per-finding explanation. Prefers the scanner's own `remediation_hint` when it
|
|
163
|
+
* is already a URL (checkov guideline, tflint rule link, trivy reference).
|
|
164
|
+
* Otherwise derives the well-known page deterministically: a trivy `AVD-*` rule
|
|
165
|
+
* maps to its Aqua Vulnerability Database page. Returns null when no canonical
|
|
166
|
+
* URL is known (the agent then explains from `evidence` alone).
|
|
167
|
+
*/
|
|
168
|
+
export function ruleDocUrl(concern: Pick<Concern, "rule_id" | "remediation_hint">): string | null {
|
|
169
|
+
const hint = concern.remediation_hint?.trim();
|
|
170
|
+
if (hint && /^https?:\/\//i.test(hint)) return hint;
|
|
171
|
+
// trivy:AVD-AWS-0088 → https://avd.aquasec.com/misconfig/avd-aws-0088
|
|
172
|
+
const trivyMatch = concern.rule_id.match(/^trivy:(AVD-[A-Z0-9-]+)$/i);
|
|
173
|
+
if (trivyMatch) return `https://avd.aquasec.com/misconfig/${trivyMatch[1]!.toLowerCase()}`;
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** distinct rule→doc-url map for a group, for the PR body's per-finding links. */
|
|
178
|
+
export function docUrlsForGroup(g: ConcernGroup, all: Concern[]): Record<string, string> {
|
|
179
|
+
const byId = new Map(all.map((c) => [c.id, c]));
|
|
180
|
+
const out: Record<string, string> = {};
|
|
181
|
+
for (const id of g.concern_ids) {
|
|
182
|
+
const c = byId.get(id);
|
|
183
|
+
if (!c) continue;
|
|
184
|
+
const url = ruleDocUrl(c);
|
|
185
|
+
if (url) out[c.rule_id] = url;
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- severity-driven autonomy (§3.9) ---------------------------------------
|
|
191
|
+
|
|
192
|
+
export interface AutonomyDecision {
|
|
193
|
+
autonomy: Autonomy;
|
|
194
|
+
/** human-readable reasons a group was escalated (empty for `auto`). */
|
|
195
|
+
reasons: string[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Decide whether a group of concerns can be auto-fixed and opened as a normal
|
|
200
|
+
* PR (`auto`), or must be flagged for human review (`needs-human`). Trivial
|
|
201
|
+
* findings (style/correctness, deprecated args, missing tags, formatting) open
|
|
202
|
+
* as normal; high-severity SECURITY findings escalate by default, as does a
|
|
203
|
+
* `high` blast radius regardless of finding severity (§2.6 overrides upward).
|
|
204
|
+
*
|
|
205
|
+
* `threshold` is the minimum severity at which a *security* concern escalates
|
|
206
|
+
* (default `high`, so critical/high security → human; medium/low → auto). The
|
|
207
|
+
* decision is deterministic and computed from the `Concern` model's existing
|
|
208
|
+
* `severity` + `category` — no model self-assessment.
|
|
209
|
+
*/
|
|
210
|
+
export function classifyAutonomy(
|
|
211
|
+
concerns: Pick<Concern, "severity" | "category">[],
|
|
212
|
+
threshold: Severity = "high",
|
|
213
|
+
blastTier?: BlastTier,
|
|
214
|
+
): AutonomyDecision {
|
|
215
|
+
const reasons: string[] = [];
|
|
216
|
+
const minRank = SEVERITY_RANK[threshold];
|
|
217
|
+
const escalating = concerns.filter(
|
|
218
|
+
(c) => c.category === "security" && SEVERITY_RANK[c.severity] >= minRank,
|
|
219
|
+
);
|
|
220
|
+
if (escalating.length > 0) {
|
|
221
|
+
const top = escalating.reduce((max, c) =>
|
|
222
|
+
SEVERITY_RANK[c.severity] > SEVERITY_RANK[max.severity] ? c : max,
|
|
223
|
+
);
|
|
224
|
+
reasons.push(
|
|
225
|
+
`${escalating.length} security concern(s) at/above the ${threshold} autonomy threshold (highest: ${top.severity})`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (blastTier === "high") {
|
|
229
|
+
reasons.push(
|
|
230
|
+
"high blast radius — the fix touches more than 10 resources or spans more than one module",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return { autonomy: reasons.length > 0 ? "needs-human" : "auto", reasons };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- inline suggested changes (§5.18) --------------------------------------
|
|
237
|
+
|
|
238
|
+
export interface SuggestionDecision {
|
|
239
|
+
/** true ⇒ post a GitHub one-click `suggestion` instead of opening a full PR. */
|
|
240
|
+
suggest: boolean;
|
|
241
|
+
reason: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* §5.18 — decide whether a fix is small/low-risk enough to post as a GitHub
|
|
246
|
+
* one-click **suggested change** (a ` ```suggestion ` block on the existing PR)
|
|
247
|
+
* rather than opening a whole `remediate/*` branch + PR. Much lower friction for
|
|
248
|
+
* trivial fixes. Only when ALL hold: there IS an existing PR context (a comment
|
|
249
|
+
* trigger on a PR); the group is `low`/`info` severity; the fix is a single hunk
|
|
250
|
+
* in a single file; and the blast radius (when known) is `low`. Anything bigger
|
|
251
|
+
* keeps full-PR mode.
|
|
252
|
+
*/
|
|
253
|
+
export function shouldSuggestInline(opts: {
|
|
254
|
+
hasPrContext: boolean;
|
|
255
|
+
severity: Severity;
|
|
256
|
+
fileCount: number;
|
|
257
|
+
hunkCount: number;
|
|
258
|
+
blastTier?: BlastTier | undefined;
|
|
259
|
+
}): SuggestionDecision {
|
|
260
|
+
if (!opts.hasPrContext)
|
|
261
|
+
return { suggest: false, reason: "no existing PR to attach a suggestion to" };
|
|
262
|
+
if (opts.severity !== "low" && opts.severity !== "info") {
|
|
263
|
+
return {
|
|
264
|
+
suggest: false,
|
|
265
|
+
reason: `severity ${opts.severity} warrants a reviewable PR, not a one-click suggestion`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (opts.fileCount > 1 || opts.hunkCount > 1) {
|
|
269
|
+
return { suggest: false, reason: "multi-hunk / multi-file fix — open a full PR" };
|
|
270
|
+
}
|
|
271
|
+
if (opts.blastTier === "high" || opts.blastTier === "medium") {
|
|
272
|
+
return { suggest: false, reason: `blast radius ${opts.blastTier} — open a full PR` };
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
suggest: true,
|
|
276
|
+
reason: "single-hunk low-risk fix on an existing PR — post as a one-click suggestion",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- confidence labeling (§5.19) -------------------------------------------
|
|
281
|
+
|
|
282
|
+
export type Confidence = "high" | "medium" | "low";
|
|
283
|
+
|
|
284
|
+
export interface ConfidenceSignals {
|
|
285
|
+
/** §1.1 — every targeted concern id was cleared by the re-scan. */
|
|
286
|
+
verified: boolean;
|
|
287
|
+
/** §1.4 — count of NEW concern ids the fix introduced (0 is good). */
|
|
288
|
+
regressionCount: number;
|
|
289
|
+
/** §1.3 — second plan matched the first. undefined when plan didn't run. */
|
|
290
|
+
idempotent?: boolean | undefined;
|
|
291
|
+
/** §2.6 — blast tier. undefined when plan didn't run. */
|
|
292
|
+
blastTier?: BlastTier | undefined;
|
|
293
|
+
/** §4.16 — cost direction. undefined when infracost didn't run. */
|
|
294
|
+
costDirection?: CostDelta["direction"] | undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface ConfidenceResult {
|
|
298
|
+
level: Confidence;
|
|
299
|
+
reasons: string[];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Derive a fix's confidence DETERMINISTICALLY from the verification evidence
|
|
304
|
+
* already gathered — never a model self-assessment, which keeps it honest.
|
|
305
|
+
*
|
|
306
|
+
* - A fix that didn't verify (§1.1) or introduced a regression (§1.4) is `low`:
|
|
307
|
+
* the proof failed, full stop.
|
|
308
|
+
* - Otherwise it starts `high` and is capped to `medium` by any weaker signal:
|
|
309
|
+
* a non-deterministic plan (§1.3 `idempotent: false`), a `high` blast radius
|
|
310
|
+
* (§2.6), a cost increase (§4.16), or a signal that was *skipped* (plan /
|
|
311
|
+
* infracost didn't run, so we have less proof — `high` requires the full
|
|
312
|
+
* stack). A skipped signal lowers confidence but does not, by itself, make a
|
|
313
|
+
* verified, regression-free fix `low`.
|
|
314
|
+
*/
|
|
315
|
+
export function computeConfidence(signals: ConfidenceSignals): ConfidenceResult {
|
|
316
|
+
const reasons: string[] = [];
|
|
317
|
+
if (!signals.verified) {
|
|
318
|
+
return {
|
|
319
|
+
level: "low",
|
|
320
|
+
reasons: ["the re-scan did not confirm every targeted concern was resolved (§1.1)"],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (signals.regressionCount > 0) {
|
|
324
|
+
return {
|
|
325
|
+
level: "low",
|
|
326
|
+
reasons: [`the fix introduced ${signals.regressionCount} new concern(s) (§1.4 regression)`],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
reasons.push(
|
|
330
|
+
"re-scan verified every targeted concern resolved (§1.1) with no regressions (§1.4)",
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
let level: Confidence = "high";
|
|
334
|
+
const capMedium = (reason: string) => {
|
|
335
|
+
if (level === "high") level = "medium";
|
|
336
|
+
reasons.push(reason);
|
|
337
|
+
};
|
|
338
|
+
if (signals.idempotent === false)
|
|
339
|
+
capMedium("plan is non-deterministic (§1.3) — a perpetual-diff smell");
|
|
340
|
+
if (signals.blastTier === "high") capMedium("high blast radius (§2.6) — review carefully");
|
|
341
|
+
if (signals.costDirection === "increase") capMedium("the fix increases monthly cost (§4.16)");
|
|
342
|
+
if (signals.idempotent === undefined || signals.blastTier === undefined) {
|
|
343
|
+
capMedium(
|
|
344
|
+
"no terraform plan evidence (no cloud credentials) — idempotency and blast radius unproven",
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (signals.costDirection === undefined) {
|
|
348
|
+
capMedium("no cost evidence (infracost did not run)");
|
|
349
|
+
}
|
|
350
|
+
return { level, reasons };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- honest refusal (§29) --------------------------------------------------
|
|
354
|
+
|
|
355
|
+
export interface RefusalDecision {
|
|
356
|
+
/** true ⇒ this concern needs a human decision; prefer a structured non-fix
|
|
357
|
+
* (an issue) over guessing a fix that could break the stack. */
|
|
358
|
+
refuse: boolean;
|
|
359
|
+
reason?: string;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// concern signatures whose correct fix needs information only a human has —
|
|
363
|
+
// auto-"fixing" them risks a narrow policy that breaks the stack or a wrong
|
|
364
|
+
// value. Matched against the rule id + evidence text (lower-cased).
|
|
365
|
+
const HUMAN_DECISION_SIGNATURES: { test: RegExp; reason: string }[] = [
|
|
366
|
+
{
|
|
367
|
+
test: /least[\s_-]?privilege|wildcard|iam.*("\*"|\*\s*action|action.*\*)|policy.*allows? all/i,
|
|
368
|
+
reason:
|
|
369
|
+
"narrowing an IAM policy needs the exact action/resource set the workload uses — a human decision",
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
test: /\bkms\b.*\bpolicy\b|key[\s_-]?policy|cmk.*polic/i,
|
|
373
|
+
reason: "a KMS/CMK key policy needs the real principals and grants — a human decision",
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
test: /allowed?[\s_-]?cidr|restrict.*cidr|specify.*cidr|known.*ip|real.*source/i,
|
|
377
|
+
reason: "tightening an ingress CIDR needs the real allowed source — a human decision",
|
|
378
|
+
},
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* §29 — advisory check: would auto-fixing this concern require a judgement only
|
|
383
|
+
* a human can make? If so, the Remediate flow should post a STRUCTURED refusal
|
|
384
|
+
* (an issue describing the concern, why it won't auto-fix, and what a human
|
|
385
|
+
* should do) rather than guess a fix that could break the stack. Deterministic
|
|
386
|
+
* and conservative — it only flags the well-known human-decision classes.
|
|
387
|
+
*/
|
|
388
|
+
export function classifyRefusal(concern: Pick<Concern, "rule_id" | "evidence">): RefusalDecision {
|
|
389
|
+
const text = `${concern.rule_id} ${concern.evidence}`.toLowerCase();
|
|
390
|
+
for (const sig of HUMAN_DECISION_SIGNATURES) {
|
|
391
|
+
if (sig.test.test(text)) return { refuse: true, reason: sig.reason };
|
|
392
|
+
}
|
|
393
|
+
return { refuse: false };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* §29 — format a structured non-fix for a concern Terramend won't auto-fix. The
|
|
398
|
+
* output is a Markdown issue body: what's wrong, why it isn't auto-fixed, and
|
|
399
|
+
* the concrete next step for a human. Pure (string in → string out).
|
|
400
|
+
*/
|
|
401
|
+
export function buildRefusalReport(input: {
|
|
402
|
+
concern: Pick<Concern, "rule_id" | "evidence" | "location">;
|
|
403
|
+
whyNoAutoFix: string;
|
|
404
|
+
humanAction: string;
|
|
405
|
+
}): string {
|
|
406
|
+
const { concern, whyNoAutoFix, humanAction } = input;
|
|
407
|
+
const loc = `${concern.location.file}${concern.location.line ? `:${concern.location.line}` : ""}`;
|
|
408
|
+
return [
|
|
409
|
+
`### Terramend won't auto-fix \`${concern.rule_id}\` (needs a human decision)`,
|
|
410
|
+
"",
|
|
411
|
+
`**Where:** \`${loc}\``,
|
|
412
|
+
"",
|
|
413
|
+
`**What's wrong:** ${concern.evidence}`,
|
|
414
|
+
"",
|
|
415
|
+
`**Why it isn't auto-fixed:** ${whyNoAutoFix}`,
|
|
416
|
+
"",
|
|
417
|
+
`**What a human should do:** ${humanAction}`,
|
|
418
|
+
"",
|
|
419
|
+
"_Terramend opens a PR only when it can prove the fix is correct; for this concern it can't, so it's surfaced here instead of guessing._",
|
|
420
|
+
].join("\n");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// --- fix once, prevent forever (§21) ---------------------------------------
|
|
424
|
+
|
|
425
|
+
export interface PreventiveControl {
|
|
426
|
+
/** the mechanism that stops this class of concern recurring. */
|
|
427
|
+
mechanism: string;
|
|
428
|
+
/** a copy-pasteable config/CI snippet. */
|
|
429
|
+
snippet: string;
|
|
430
|
+
note: string;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* §21 — alongside the patch, suggest the guardrail that stops the concern
|
|
435
|
+
* RECURRING: a CI gate keyed on the producing scanner. Deterministic by source
|
|
436
|
+
* (the scanner is the right enforcement point), parameterised by the rule id.
|
|
437
|
+
* Returns null for sources with no natural preventive gate.
|
|
438
|
+
*/
|
|
439
|
+
export function preventiveControlFor(
|
|
440
|
+
concern: Pick<Concern, "source" | "rule_id">,
|
|
441
|
+
): PreventiveControl | null {
|
|
442
|
+
// strip only the leading `<source>:` namespace (not every colon) so a rule
|
|
443
|
+
// name that itself contains a colon survives intact.
|
|
444
|
+
const prefix = `${concern.source}:`;
|
|
445
|
+
const bareRule = concern.rule_id.startsWith(prefix)
|
|
446
|
+
? concern.rule_id.slice(prefix.length)
|
|
447
|
+
: concern.rule_id;
|
|
448
|
+
switch (concern.source) {
|
|
449
|
+
case "checkov":
|
|
450
|
+
return {
|
|
451
|
+
mechanism: "Checkov hard-fail in CI",
|
|
452
|
+
snippet: `# .checkov.yaml\nhard-fail-on:\n - ${bareRule}`,
|
|
453
|
+
note: `Add ${bareRule} to a Checkov hard-fail list so a PR that reintroduces it fails CI.`,
|
|
454
|
+
};
|
|
455
|
+
case "trivy":
|
|
456
|
+
return {
|
|
457
|
+
mechanism: "Trivy config scan gate in CI",
|
|
458
|
+
snippet: `# CI step\ntrivy config --exit-code 1 --severity HIGH,CRITICAL .`,
|
|
459
|
+
note: `Gate PRs on \`trivy config\` so ${bareRule} (and peers) can't be reintroduced.`,
|
|
460
|
+
};
|
|
461
|
+
case "tflint":
|
|
462
|
+
return {
|
|
463
|
+
mechanism: "tflint rule enforced in CI",
|
|
464
|
+
snippet: `# .tflint.hcl\nrule "${bareRule}" {\n enabled = true\n}`,
|
|
465
|
+
note: `Enable ${bareRule} in \`.tflint.hcl\` and run \`tflint\` in CI.`,
|
|
466
|
+
};
|
|
467
|
+
case "terraform-fmt":
|
|
468
|
+
return {
|
|
469
|
+
mechanism: "terraform fmt check in CI",
|
|
470
|
+
snippet: `# CI step\nterraform fmt -check -recursive`,
|
|
471
|
+
note: "Gate PRs on `terraform fmt -check` so formatting can't drift.",
|
|
472
|
+
};
|
|
473
|
+
case "terraform-validate":
|
|
474
|
+
return {
|
|
475
|
+
mechanism: "terraform validate in CI",
|
|
476
|
+
snippet: `# CI step\nterraform validate`,
|
|
477
|
+
note: "Run `terraform validate` in CI so this correctness error can't return.",
|
|
478
|
+
};
|
|
479
|
+
default:
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// --- cross-tool co-location (§30) ------------------------------------------
|
|
485
|
+
|
|
486
|
+
export interface LocationCluster {
|
|
487
|
+
file: string;
|
|
488
|
+
line: number | null;
|
|
489
|
+
/** the concern ids at this exact location (likely the same underlying defect). */
|
|
490
|
+
concern_ids: string[];
|
|
491
|
+
/** the distinct scanners that flagged this location. */
|
|
492
|
+
sources: string[];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* §30 — surface concerns that DIFFERENT scanners flagged at the same `file:line`
|
|
497
|
+
* — almost always the same underlying defect (trivy ∩ checkov overlap heavily on
|
|
498
|
+
* e.g. S3 encryption). Reported so the agent writes ONE canonical fix + ONE
|
|
499
|
+
* explanation for the cluster rather than treating each as separate work. This
|
|
500
|
+
* is purely advisory: it NEVER removes a concern from the verification set (a
|
|
501
|
+
* missing id must still provably clear), so it can't drop a real finding. Only
|
|
502
|
+
* clusters spanning more than one scanner are returned.
|
|
503
|
+
*/
|
|
504
|
+
export function clusterByLocation(concerns: Concern[]): LocationCluster[] {
|
|
505
|
+
const byLoc = new Map<string, Concern[]>();
|
|
506
|
+
for (const c of concerns) {
|
|
507
|
+
if (c.location.line == null) continue; // a null line isn't a precise co-location
|
|
508
|
+
const key = `${c.location.file}|${c.location.line}`;
|
|
509
|
+
const arr = byLoc.get(key) ?? [];
|
|
510
|
+
arr.push(c);
|
|
511
|
+
byLoc.set(key, arr);
|
|
512
|
+
}
|
|
513
|
+
const clusters: LocationCluster[] = [];
|
|
514
|
+
for (const cs of byLoc.values()) {
|
|
515
|
+
const first = cs[0];
|
|
516
|
+
if (!first) continue; // empty group can't happen (map is populated), but keep the guard explicit
|
|
517
|
+
const sources = [...new Set(cs.map((c) => c.source))].sort();
|
|
518
|
+
if (sources.length < 2) continue; // single-scanner location isn't cross-tool overlap
|
|
519
|
+
clusters.push({
|
|
520
|
+
file: first.location.file,
|
|
521
|
+
line: first.location.line,
|
|
522
|
+
concern_ids: cs.map((c) => c.id).sort(),
|
|
523
|
+
sources,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return clusters.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
|
|
527
|
+
}
|