terramend 0.2.0 → 0.2.1
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/dist/agents/claudePretoolGate.d.ts +2 -2
- package/dist/cli.mjs +16554 -8100
- package/dist/index.js +13484 -5037
- package/dist/internal.js +75 -11
- package/dist/mcp/assess.d.ts +86 -0
- package/dist/mcp/changeSummary.d.ts +50 -0
- package/dist/mcp/crosswalk.d.ts +5 -0
- package/dist/mcp/localContext.d.ts +1 -1
- package/dist/mcp/terraform/evidence.d.ts +99 -0
- package/dist/mcp/terraform/scanners.d.ts +38 -3
- package/dist/mcp/terraform/types.d.ts +16 -0
- package/dist/mcp/terraform/verification.d.ts +74 -0
- package/dist/mcp/terraform.d.ts +4 -0
- package/dist/modes.d.ts +1 -1
- package/dist/toolState.d.ts +1 -0
- package/dist/utils/moduleFetch.d.ts +42 -0
- package/dist/utils/payload.d.ts +4 -0
- package/dist/utils/remediationCommand.d.ts +3 -0
- package/dist/utils/terraformMcp.d.ts +2 -2
- package/dist/utils/terramendConfig.d.ts +51 -0
- package/dist/utils/toolLicensing.d.ts +56 -0
- package/dist/utils/toolSelection.d.ts +72 -0
- package/package.json +9 -8
- package/src/agents/claudePretoolGate.ts +3 -3
- package/src/mcp/assess.test.ts +135 -0
- package/src/mcp/assess.ts +341 -0
- package/src/mcp/changeSummary.test.ts +94 -0
- package/src/mcp/changeSummary.ts +145 -0
- package/src/mcp/crosswalk.ts +15 -1
- package/src/mcp/guardrails.ts +11 -6
- package/src/mcp/localContext.ts +7 -0
- package/src/mcp/localServer.test.ts +2 -0
- package/src/mcp/localServer.ts +14 -0
- package/src/mcp/server.ts +6 -0
- package/src/mcp/terraform/evidence.test.ts +72 -0
- package/src/mcp/terraform/evidence.ts +187 -0
- package/src/mcp/terraform/scanners.ts +86 -9
- package/src/mcp/terraform/tools.test.ts +96 -1
- package/src/mcp/terraform/tools.ts +115 -32
- package/src/mcp/terraform/types.ts +24 -0
- package/src/mcp/terraform/verification.test.ts +85 -0
- package/src/mcp/terraform/verification.ts +133 -0
- package/src/mcp/terraform.test.ts +108 -0
- package/src/mcp/terraform.ts +4 -0
- package/src/modes.test.ts +9 -1
- package/src/modes.ts +81 -11
- package/src/toolState.ts +6 -0
- package/src/utils/moduleFetch.test.ts +68 -0
- package/src/utils/moduleFetch.ts +86 -0
- package/src/utils/payload.test.ts +66 -1
- package/src/utils/payload.ts +39 -11
- package/src/utils/remediationCommand.test.ts +32 -0
- package/src/utils/remediationCommand.ts +11 -0
- package/src/utils/terraformMcp.ts +6 -5
- package/src/utils/terramendConfig.test.ts +98 -0
- package/src/utils/terramendConfig.ts +143 -0
- package/src/utils/toolLicensing.test.ts +54 -0
- package/src/utils/toolLicensing.ts +103 -0
- package/src/utils/toolSelection.test.ts +140 -0
- package/src/utils/toolSelection.ts +231 -0
package/src/mcp/localServer.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { tmpdir } from "node:os";
|
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { FastMCP, type Tool } from "fastmcp";
|
|
23
23
|
import { terramendMcpName } from "#app/external";
|
|
24
|
+
import { TerraformAssessTool } from "#app/mcp/assess";
|
|
24
25
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
25
26
|
import { ModuleExtractionCandidatesTool } from "#app/mcp/moduleExtraction";
|
|
26
27
|
import {
|
|
@@ -34,6 +35,7 @@ import { TerraformRootsTool } from "#app/mcp/roots";
|
|
|
34
35
|
import {
|
|
35
36
|
InfracostDiffTool,
|
|
36
37
|
ReadFindingsTool,
|
|
38
|
+
TerraformEmitEvidenceTool,
|
|
37
39
|
TerraformEmitSarifTool,
|
|
38
40
|
TerraformPlanTool,
|
|
39
41
|
TerraformScanTool,
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
} from "#app/mcp/terraform";
|
|
44
46
|
import { initToolState } from "#app/toolState";
|
|
45
47
|
import { log } from "#app/utils/cli";
|
|
48
|
+
import { parseToolSelection } from "#app/utils/toolSelection";
|
|
46
49
|
|
|
47
50
|
export interface LocalMcpOptions {
|
|
48
51
|
/** absolute workspace directory the tools operate on. */
|
|
@@ -51,6 +54,10 @@ export interface LocalMcpOptions {
|
|
|
51
54
|
scanScope?: "full" | "diff" | undefined;
|
|
52
55
|
/** newline/comma-separated approved module list (same as the action input). */
|
|
53
56
|
moduleCatalogue?: string | undefined;
|
|
57
|
+
/** §1.5 — the unified tool-selection list (same syntax as the action input). */
|
|
58
|
+
toolsEnabled?: string | undefined;
|
|
59
|
+
/** §1.5 — scoped token to fetch private cross-repo `git::` modules at init. */
|
|
60
|
+
moduleFetchToken?: string | undefined;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
/** build the cwd-scoped context the read-only tools run against. */
|
|
@@ -63,6 +70,11 @@ export function buildLocalContext(options: LocalMcpOptions): LocalToolContext {
|
|
|
63
70
|
autonomyThreshold: undefined,
|
|
64
71
|
costIncreaseBlockUsd: undefined,
|
|
65
72
|
moduleCatalogue: options.moduleCatalogue,
|
|
73
|
+
toolsEnabled: parseToolSelection(options.toolsEnabled),
|
|
74
|
+
gitleaks: false,
|
|
75
|
+
terratest: false,
|
|
76
|
+
terraformMcp: false,
|
|
77
|
+
moduleFetchToken: options.moduleFetchToken,
|
|
66
78
|
},
|
|
67
79
|
toolState: initToolState({ progressComment: undefined }),
|
|
68
80
|
tmpdir: mkdtempSync(join(tmpdir(), "terramend-mcp-")),
|
|
@@ -77,6 +89,7 @@ export function buildLocalContext(options: LocalMcpOptions): LocalToolContext {
|
|
|
77
89
|
export function buildLocalTools(ctx: LocalToolContext): Tool<any, any>[] {
|
|
78
90
|
return [
|
|
79
91
|
TerraformScanTool(ctx),
|
|
92
|
+
TerraformAssessTool(ctx),
|
|
80
93
|
TerraformValidateTool(ctx),
|
|
81
94
|
TerraformVerifyRemediationTool(ctx),
|
|
82
95
|
TerraformPlanTool(ctx),
|
|
@@ -84,6 +97,7 @@ export function buildLocalTools(ctx: LocalToolContext): Tool<any, any>[] {
|
|
|
84
97
|
InfracostDiffTool(ctx),
|
|
85
98
|
ReadFindingsTool(ctx),
|
|
86
99
|
TerraformEmitSarifTool(ctx),
|
|
100
|
+
TerraformEmitEvidenceTool(ctx),
|
|
87
101
|
ListModulesTool(ctx),
|
|
88
102
|
TerraformModuleGraphTool(ctx),
|
|
89
103
|
TerraformModuleInterfaceTool(ctx),
|
package/src/mcp/server.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { createServer } from "node:net";
|
|
|
5
5
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
6
|
import { FastMCP, type Tool } from "fastmcp";
|
|
7
7
|
import { type AgentId, terramendMcpName } from "#app/external";
|
|
8
|
+
import { TerraformAssessTool } from "#app/mcp/assess";
|
|
9
|
+
import { TerraformChangeSummaryTool } from "#app/mcp/changeSummary";
|
|
8
10
|
import { CheckoutPrTool } from "#app/mcp/checkout";
|
|
9
11
|
import { GetCheckSuiteLogsTool } from "#app/mcp/checkSuite";
|
|
10
12
|
import {
|
|
@@ -57,6 +59,7 @@ import { ClosePullRequestTool, ListRemediationPrsTool } from "#app/mcp/staleFix"
|
|
|
57
59
|
import {
|
|
58
60
|
InfracostDiffTool,
|
|
59
61
|
ReadFindingsTool,
|
|
62
|
+
TerraformEmitEvidenceTool,
|
|
60
63
|
TerraformEmitSarifTool,
|
|
61
64
|
TerraformPlanTool,
|
|
62
65
|
TerraformScanTool,
|
|
@@ -166,6 +169,7 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
|
|
|
166
169
|
PullRequestInfoTool(ctx),
|
|
167
170
|
CommitInfoTool(ctx),
|
|
168
171
|
CheckoutPrTool(ctx),
|
|
172
|
+
TerraformChangeSummaryTool(ctx),
|
|
169
173
|
GetReviewCommentsTool(ctx),
|
|
170
174
|
ListPullRequestReviewsTool(ctx),
|
|
171
175
|
ResolveReviewThreadTool(ctx),
|
|
@@ -177,6 +181,7 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
|
|
|
177
181
|
// Terraform best-practice check tools (read-only). Always available so the
|
|
178
182
|
// Remediate / GenerateTerraform modes can scan + gate without extra perms.
|
|
179
183
|
TerraformScanTool(ctx),
|
|
184
|
+
TerraformAssessTool(ctx),
|
|
180
185
|
TerraformValidateTool(ctx),
|
|
181
186
|
TerraformVerifyRemediationTool(ctx),
|
|
182
187
|
InfracostDiffTool(ctx),
|
|
@@ -192,6 +197,7 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
|
|
|
192
197
|
TerraformRootsTool(ctx),
|
|
193
198
|
ScaffoldTerratestTool(ctx),
|
|
194
199
|
TerraformEmitSarifTool(ctx),
|
|
200
|
+
TerraformEmitEvidenceTool(ctx),
|
|
195
201
|
PolicyCheckTool(ctx),
|
|
196
202
|
ComplianceCrosswalkTool(ctx),
|
|
197
203
|
];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildAssessment } from "#app/mcp/assess";
|
|
3
|
+
import { buildCrosswalkReport } from "#app/mcp/crosswalk";
|
|
4
|
+
import { buildEvidenceBundle, EVIDENCE_SCHEMA } from "#app/mcp/terraform/evidence";
|
|
5
|
+
import type { Concern } from "#app/mcp/terraform/types";
|
|
6
|
+
import { buildVerificationSummary } from "#app/mcp/terraform/verification";
|
|
7
|
+
|
|
8
|
+
let n = 0;
|
|
9
|
+
function concern(partial: Partial<Concern> = {}): Concern {
|
|
10
|
+
n += 1;
|
|
11
|
+
return {
|
|
12
|
+
id: partial.id ?? `id${n}`,
|
|
13
|
+
source: partial.source ?? "trivy",
|
|
14
|
+
rule_id: partial.rule_id ?? "trivy:AVD-AWS-0088",
|
|
15
|
+
severity: partial.severity ?? "high",
|
|
16
|
+
category: partial.category ?? "security",
|
|
17
|
+
evidence: partial.evidence ?? "S3 bucket is unencrypted at rest",
|
|
18
|
+
location: partial.location ?? { file: "main.tf", line: 1 },
|
|
19
|
+
remediation_hint: null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function bundleFor(concerns: Concern[]) {
|
|
24
|
+
const crosswalk = buildCrosswalkReport(
|
|
25
|
+
concerns.map((c) => ({ id: c.id, rule_id: c.rule_id, evidence: c.evidence })),
|
|
26
|
+
);
|
|
27
|
+
const scorecard = buildAssessment(concerns, crosswalk, buildVerificationSummary(concerns, []));
|
|
28
|
+
return buildEvidenceBundle({
|
|
29
|
+
scorecard,
|
|
30
|
+
crosswalk,
|
|
31
|
+
subject: { scanned_dir: "/repo", repo: "acme/infra", ref: "main", commit: "abc123" },
|
|
32
|
+
generatedAt: "2026-06-13T00:00:00.000Z",
|
|
33
|
+
version: "0.2.0",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("buildEvidenceBundle", () => {
|
|
38
|
+
it("packages posture, subject, and the caller's timestamp under our own schema (not OSCAL)", () => {
|
|
39
|
+
const b = bundleFor([concern()]);
|
|
40
|
+
expect(b.schema).toBe(EVIDENCE_SCHEMA);
|
|
41
|
+
expect(b.schema).not.toMatch(/oscal/i);
|
|
42
|
+
expect(b.generated_at).toBe("2026-06-13T00:00:00.000Z");
|
|
43
|
+
expect(b.tool).toEqual({ name: "terramend", version: "0.2.0" });
|
|
44
|
+
expect(b.subject).toMatchObject({ repo: "acme/infra", ref: "main", commit: "abc123" });
|
|
45
|
+
expect(b.posture).toBe("action-required");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("emits one control statement per mapped concern, each carrying its status + controls", () => {
|
|
49
|
+
const b = bundleFor([
|
|
50
|
+
concern({ id: "enc", rule_id: "trivy:AVD-AWS-0088", evidence: "unencrypted at rest" }),
|
|
51
|
+
concern({ id: "iam", rule_id: "checkov:CKV_AWS_1", evidence: "IAM wildcard * policy" }),
|
|
52
|
+
]);
|
|
53
|
+
const byId = Object.fromEntries(b.control_statements.map((s) => [s.concern_id, s]));
|
|
54
|
+
expect(byId.enc?.status).toBe("fail");
|
|
55
|
+
expect(byId.iam?.status).toBe("not-code-verifiable");
|
|
56
|
+
expect(byId.enc?.controls.length).toBeGreaterThan(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("never claims a pass it can't verify — carries the legend + honest disclaimer", () => {
|
|
60
|
+
const b = bundleFor([concern()]);
|
|
61
|
+
expect(Object.keys(b.legend)).toContain("not-code-verifiable");
|
|
62
|
+
expect(b.legend.inconclusive).toMatch(/not a pass/i);
|
|
63
|
+
expect(b.disclaimer).toMatch(/not an audit verdict/i);
|
|
64
|
+
expect(b.disclaimer).toMatch(/absence of a finding is not proof/i);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is deterministic for the same inputs (reproducible evidence)", () => {
|
|
68
|
+
const a = JSON.stringify(bundleFor([concern({ id: "x" })]));
|
|
69
|
+
const b = JSON.stringify(bundleFor([concern({ id: "x" })]));
|
|
70
|
+
expect(a).toBe(b);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { type } from "arktype";
|
|
3
|
+
import { type AssessmentScorecard, runAssessmentPipeline } from "#app/mcp/assess";
|
|
4
|
+
import type { CrosswalkReport } from "#app/mcp/crosswalk";
|
|
5
|
+
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
6
|
+
import { resolveWithinCwd } from "#app/mcp/pathSafety";
|
|
7
|
+
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
8
|
+
import type { Severity } from "#app/mcp/terraform/types";
|
|
9
|
+
import {
|
|
10
|
+
VERIFICATION_STATUS_LABEL,
|
|
11
|
+
type VerificationStatus,
|
|
12
|
+
type VerificationSummary,
|
|
13
|
+
} from "#app/mcp/terraform/verification";
|
|
14
|
+
import { log } from "#app/utils/cli";
|
|
15
|
+
import packageJson from "#package.json" with { type: "json" };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Backend-free compliance evidence bundle (the WS4a wedge — an auditor-facing
|
|
19
|
+
* artifact the OSS action can emit with **zero cloud and no backend**, committed
|
|
20
|
+
* to a `compliance/` path). It packages the read-only assessment — posture,
|
|
21
|
+
* per-control statements with their five-status verdict ([[verification]]), the
|
|
22
|
+
* crosswalk index, and the scanner coverage — into one deterministic JSON file.
|
|
23
|
+
*
|
|
24
|
+
* SCHEMA / HONESTY: this is Terramend's OWN structured schema, NOT OSCAL. A
|
|
25
|
+
* strict OSCAL (or compliance-trestle / C2P) emitter is a deliberate follow-up,
|
|
26
|
+
* gated on a buyer who actually needs OSCAL — emitting OSCAL nobody consumes is
|
|
27
|
+
* cost without value. The bundle is INDICATIVE alignment guidance, never an audit
|
|
28
|
+
* verdict, and it never claims `pass` for an unfired control (absence of a
|
|
29
|
+
* finding is not proof). Pure builder + a thin file-writing tool.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const EVIDENCE_SCHEMA = "terramend-evidence/v0.1" as const;
|
|
33
|
+
export const DEFAULT_EVIDENCE_PATH = "compliance/terramend-evidence.json";
|
|
34
|
+
|
|
35
|
+
export interface EvidenceControlStatement {
|
|
36
|
+
concern_id: string;
|
|
37
|
+
rule_id: string;
|
|
38
|
+
/** the five-status verdict for this statement (fail / not-code-verifiable). */
|
|
39
|
+
status: VerificationStatus;
|
|
40
|
+
severity?: string | undefined;
|
|
41
|
+
file?: string | undefined;
|
|
42
|
+
line?: number | null | undefined;
|
|
43
|
+
controls: { framework: string; control: string; title: string }[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EvidenceBundle {
|
|
47
|
+
/** Terramend's own schema id — explicitly NOT OSCAL (see module note). */
|
|
48
|
+
schema: typeof EVIDENCE_SCHEMA;
|
|
49
|
+
/** caller-supplied ISO timestamp (kept out of the builder so it stays pure). */
|
|
50
|
+
generated_at: string;
|
|
51
|
+
tool: { name: "terramend"; version: string };
|
|
52
|
+
subject: {
|
|
53
|
+
scanned_dir: string;
|
|
54
|
+
repo?: string | undefined;
|
|
55
|
+
ref?: string | undefined;
|
|
56
|
+
commit?: string | undefined;
|
|
57
|
+
};
|
|
58
|
+
posture: AssessmentScorecard["posture"];
|
|
59
|
+
summary: {
|
|
60
|
+
total: number;
|
|
61
|
+
by_severity: AssessmentScorecard["by_severity"];
|
|
62
|
+
verification: VerificationSummary["counts"];
|
|
63
|
+
};
|
|
64
|
+
/** one statement per mapped concern, each carrying its status + controls. */
|
|
65
|
+
control_statements: EvidenceControlStatement[];
|
|
66
|
+
/** which scanners code-verified vs which were inconclusive (coverage gaps). */
|
|
67
|
+
coverage: VerificationSummary["coverage"];
|
|
68
|
+
crosswalk: {
|
|
69
|
+
version: string;
|
|
70
|
+
reviewed: string;
|
|
71
|
+
by_framework: CrosswalkReport["by_framework"];
|
|
72
|
+
};
|
|
73
|
+
/** the five-status legend, so the bundle is self-describing for an assessor. */
|
|
74
|
+
legend: Record<VerificationStatus, string>;
|
|
75
|
+
disclaimer: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DISCLAIMER =
|
|
79
|
+
"Indicative alignment guidance from a deterministic starter rule-pack — NOT an " +
|
|
80
|
+
"audit verdict. Statuses are code-verified only; an inconclusive check is a " +
|
|
81
|
+
"coverage gap (not a pass) and absence of a finding is not proof of compliance.";
|
|
82
|
+
|
|
83
|
+
export interface EvidenceSubject {
|
|
84
|
+
scanned_dir: string;
|
|
85
|
+
repo?: string | undefined;
|
|
86
|
+
ref?: string | undefined;
|
|
87
|
+
commit?: string | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the evidence bundle from an assessment's scorecard + crosswalk. Pure —
|
|
92
|
+
* `generatedAt` and the subject identifiers are passed in so the same inputs
|
|
93
|
+
* always produce the same bytes (and tests don't need a clock). Control
|
|
94
|
+
* statements come from the crosswalk entries (which already carry the
|
|
95
|
+
* verification status), enriched with the concern's severity/location.
|
|
96
|
+
*/
|
|
97
|
+
export function buildEvidenceBundle(args: {
|
|
98
|
+
scorecard: AssessmentScorecard;
|
|
99
|
+
crosswalk: CrosswalkReport;
|
|
100
|
+
subject: EvidenceSubject;
|
|
101
|
+
generatedAt: string;
|
|
102
|
+
version?: string;
|
|
103
|
+
}): EvidenceBundle {
|
|
104
|
+
const { scorecard, crosswalk, subject, generatedAt } = args;
|
|
105
|
+
const control_statements: EvidenceControlStatement[] = crosswalk.entries.map((e) => ({
|
|
106
|
+
concern_id: e.concern_id,
|
|
107
|
+
rule_id: e.rule_id,
|
|
108
|
+
status: e.status,
|
|
109
|
+
controls: e.controls,
|
|
110
|
+
}));
|
|
111
|
+
return {
|
|
112
|
+
schema: EVIDENCE_SCHEMA,
|
|
113
|
+
generated_at: generatedAt,
|
|
114
|
+
tool: { name: "terramend", version: args.version ?? packageJson.version },
|
|
115
|
+
subject,
|
|
116
|
+
posture: scorecard.posture,
|
|
117
|
+
summary: {
|
|
118
|
+
total: scorecard.total,
|
|
119
|
+
by_severity: scorecard.by_severity,
|
|
120
|
+
verification: scorecard.verification.counts,
|
|
121
|
+
},
|
|
122
|
+
control_statements,
|
|
123
|
+
coverage: scorecard.verification.coverage,
|
|
124
|
+
crosswalk: {
|
|
125
|
+
version: crosswalk.version,
|
|
126
|
+
reviewed: crosswalk.reviewed,
|
|
127
|
+
by_framework: crosswalk.by_framework,
|
|
128
|
+
},
|
|
129
|
+
legend: VERIFICATION_STATUS_LABEL,
|
|
130
|
+
disclaimer: DISCLAIMER,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const TerraformEmitEvidenceParams = type({
|
|
135
|
+
"output_path?": type.string.describe(
|
|
136
|
+
"where to write the evidence bundle (default: ./compliance/terramend-evidence.json in the workspace). Commit it so the compliance/ path is the auditable evidence trail.",
|
|
137
|
+
),
|
|
138
|
+
"severity_threshold?": type("'critical' | 'high' | 'medium' | 'low' | 'info'").describe(
|
|
139
|
+
"minimum severity to include (default: the run's configured threshold, else low).",
|
|
140
|
+
),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
export function TerraformEmitEvidenceTool(ctx: LocalToolContext) {
|
|
144
|
+
return tool({
|
|
145
|
+
name: "terraform_emit_evidence",
|
|
146
|
+
description:
|
|
147
|
+
"Emit a backend-free compliance EVIDENCE BUNDLE (auditor-facing JSON) for the workspace — the wedge that " +
|
|
148
|
+
"produces assessor-ready evidence with no cloud and no backend. Runs the read-only assessment and writes " +
|
|
149
|
+
"a deterministic bundle (default `compliance/terramend-evidence.json`): overall `posture`, per-control " +
|
|
150
|
+
"statements each with a five-status verdict (fail / not-code-verifiable), the scanner `coverage` " +
|
|
151
|
+
"(verified vs inconclusive), and the indicative crosswalk index. Commit the file so `compliance/` is the " +
|
|
152
|
+
"auditable trail. It NEVER modifies Terraform or opens a PR. The schema is Terramend's own (not OSCAL); " +
|
|
153
|
+
"the bundle is indicative alignment guidance, never an audit verdict, and never claims a pass it can't " +
|
|
154
|
+
"code-verify. Pairs with `terraform_assess` (the human-readable report) and `terraform_emit_sarif`.",
|
|
155
|
+
parameters: TerraformEmitEvidenceParams,
|
|
156
|
+
execute: execute(async ({ output_path, severity_threshold }) => {
|
|
157
|
+
const configured = ctx.payload.severityThreshold as Severity | undefined;
|
|
158
|
+
const threshold: Severity = severity_threshold ?? configured ?? "low";
|
|
159
|
+
const { cwd, scorecard, crosswalk } = runAssessmentPipeline(ctx, threshold);
|
|
160
|
+
|
|
161
|
+
// SECURITY: confine the agent-supplied path to the workspace (same guard as
|
|
162
|
+
// terraform_emit_sarif) so it can't clobber arbitrary files on the runner.
|
|
163
|
+
const target = resolveWithinCwd(cwd, output_path ?? DEFAULT_EVIDENCE_PATH);
|
|
164
|
+
const bundle = buildEvidenceBundle({
|
|
165
|
+
scorecard,
|
|
166
|
+
crosswalk,
|
|
167
|
+
subject: {
|
|
168
|
+
scanned_dir: cwd,
|
|
169
|
+
repo: process.env.GITHUB_REPOSITORY,
|
|
170
|
+
ref: process.env.GITHUB_REF_NAME,
|
|
171
|
+
commit: process.env.GITHUB_SHA,
|
|
172
|
+
},
|
|
173
|
+
generatedAt: new Date().toISOString(),
|
|
174
|
+
});
|
|
175
|
+
writeFileSync(target, `${JSON.stringify(bundle, null, 2)}\n`);
|
|
176
|
+
log.info(
|
|
177
|
+
`» terraform_emit_evidence: ${scorecard.posture} — ${bundle.control_statements.length} control statement(s) → ${target}`,
|
|
178
|
+
);
|
|
179
|
+
return toolOk({
|
|
180
|
+
output_path: target,
|
|
181
|
+
posture: bundle.posture,
|
|
182
|
+
statements: bundle.control_statements.length,
|
|
183
|
+
verification: bundle.summary.verification,
|
|
184
|
+
});
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
toRepoRelative,
|
|
19
19
|
} from "#app/mcp/terraform/types";
|
|
20
20
|
import { log } from "#app/utils/cli";
|
|
21
|
+
import { type ResolvedToolSelection, scannerToolId } from "#app/utils/toolSelection";
|
|
21
22
|
|
|
22
23
|
// dirs already `terraform init`-ed this process, so repeated scans don't re-init.
|
|
23
24
|
const initedDirs = new Set<string>();
|
|
@@ -30,9 +31,16 @@ const initedDirs = new Set<string>();
|
|
|
30
31
|
* `-input=false` keeps it non-interactive. Network-dependent and best-effort: if
|
|
31
32
|
* it fails (offline, private module, etc.) validate still runs, just shallow.
|
|
32
33
|
*/
|
|
33
|
-
function ensureTerraformInit(cwd: string): void {
|
|
34
|
+
function ensureTerraformInit(cwd: string, extraEnv?: Record<string, string>): void {
|
|
34
35
|
if (initedDirs.has(cwd)) return;
|
|
35
|
-
|
|
36
|
+
// `extraEnv` carries the optional module-fetch credential (§1.5) so init can
|
|
37
|
+
// resolve a private cross-repo `git::` module; absent for the common case.
|
|
38
|
+
const r = run(
|
|
39
|
+
"terraform",
|
|
40
|
+
["init", "-backend=false", "-input=false", "-no-color"],
|
|
41
|
+
cwd,
|
|
42
|
+
extraEnv,
|
|
43
|
+
);
|
|
36
44
|
// mark done even on non-zero: a failed init won't succeed on retry within the
|
|
37
45
|
// same run, and we don't want to re-run it for every scanner call.
|
|
38
46
|
initedDirs.add(cwd);
|
|
@@ -93,8 +101,8 @@ const VALIDATE_NOISE = [
|
|
|
93
101
|
];
|
|
94
102
|
|
|
95
103
|
/** run `terraform validate` in one root and return concerns re-based onto cwd. */
|
|
96
|
-
function scanValidateRoot(root: ResolvedRoot): ScannerOutcome {
|
|
97
|
-
ensureTerraformInit(root.absDir);
|
|
104
|
+
function scanValidateRoot(root: ResolvedRoot, extraEnv?: Record<string, string>): ScannerOutcome {
|
|
105
|
+
ensureTerraformInit(root.absDir, extraEnv);
|
|
98
106
|
const r = run("terraform", ["validate", "-json"], root.absDir);
|
|
99
107
|
if (r.missing) return skipped("terraform-validate", "terraform not installed");
|
|
100
108
|
try {
|
|
@@ -120,14 +128,14 @@ function scanValidateRoot(root: ResolvedRoot): ScannerOutcome {
|
|
|
120
128
|
* whole tree), so a multi-root repo only catches subdir-root validate errors
|
|
121
129
|
* when we visit each root.
|
|
122
130
|
*/
|
|
123
|
-
export function scanValidate(cwd: string): ScannerOutcome {
|
|
131
|
+
export function scanValidate(cwd: string, extraEnv?: Record<string, string>): ScannerOutcome {
|
|
124
132
|
const roots = resolveRoots(cwd);
|
|
125
133
|
const concerns: Concern[] = [];
|
|
126
134
|
let anyRan = false;
|
|
127
135
|
let sawMissing = false;
|
|
128
136
|
let unvalidated = 0;
|
|
129
137
|
for (const root of roots) {
|
|
130
|
-
const outcome = scanValidateRoot(root);
|
|
138
|
+
const outcome = scanValidateRoot(root, extraEnv);
|
|
131
139
|
unvalidated += outcome.unvalidated ?? 0;
|
|
132
140
|
if (outcome.ran) {
|
|
133
141
|
anyRan = true;
|
|
@@ -747,10 +755,37 @@ export function changedTerraformFiles(cwd: string): Set<string> | null {
|
|
|
747
755
|
return new Set(files);
|
|
748
756
|
}
|
|
749
757
|
|
|
758
|
+
export interface RunScannersOptions {
|
|
759
|
+
/** §1.5 — the resolved tool selection. A scanner whose tool is gated/disabled
|
|
760
|
+
* is reported as a `skipped` outcome (with the licence/disable reason) instead
|
|
761
|
+
* of running, so `terraform_scan` and the ✗→✓ verifier see the SAME toolchain. */
|
|
762
|
+
selection?: ResolvedToolSelection | undefined;
|
|
763
|
+
/** §1.5 — module-fetch credential env, threaded into `terraform validate`'s
|
|
764
|
+
* init so a private cross-repo `git::` module resolves during a scan. */
|
|
765
|
+
terraformEnv?: Record<string, string> | undefined;
|
|
766
|
+
}
|
|
767
|
+
|
|
750
768
|
/** run every scanner once over `cwd`. shared by `terraform_scan` and the
|
|
751
|
-
* deterministic remediation verifier so both see the identical toolchain.
|
|
752
|
-
|
|
753
|
-
|
|
769
|
+
* deterministic remediation verifier so both see the identical toolchain. A
|
|
770
|
+
* scanner the selection has turned off is emitted as `skipped` (never run), so
|
|
771
|
+
* the gate applies identically to the scan and its verification re-scan. */
|
|
772
|
+
export function runScanners(cwd: string, opts: RunScannersOptions = {}): ScannerOutcome[] {
|
|
773
|
+
const { selection, terraformEnv } = opts;
|
|
774
|
+
const gate = (source: Concern["source"], runner: () => ScannerOutcome): ScannerOutcome => {
|
|
775
|
+
if (!selection) return runner();
|
|
776
|
+
const id = scannerToolId(source);
|
|
777
|
+
if (id && !selection.enabled(id)) {
|
|
778
|
+
return skipped(source, selection.offReason(id) ?? "disabled by tools_enabled");
|
|
779
|
+
}
|
|
780
|
+
return runner();
|
|
781
|
+
};
|
|
782
|
+
return [
|
|
783
|
+
gate("terraform-fmt", () => scanFmt(cwd)),
|
|
784
|
+
gate("terraform-validate", () => scanValidate(cwd, terraformEnv)),
|
|
785
|
+
gate("tflint", () => scanTflint(cwd)),
|
|
786
|
+
gate("trivy", () => scanTrivy(cwd)),
|
|
787
|
+
gate("checkov", () => scanCheckov(cwd)),
|
|
788
|
+
];
|
|
754
789
|
}
|
|
755
790
|
|
|
756
791
|
export interface RemediationVerdict {
|
|
@@ -807,3 +842,45 @@ export function computeRegressions(
|
|
|
807
842
|
}
|
|
808
843
|
return [...regressions].sort();
|
|
809
844
|
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Line-INDEPENDENT ✗→✓ partition (the integrity-preserving replacement for the
|
|
848
|
+
* raw-id `computeRemediationVerdict` when scan context is available). Each
|
|
849
|
+
* requested entry carries its display `id` and its `key` (see `concernKeyOf`);
|
|
850
|
+
* a concern is `remaining` iff its KEY still appears in the re-scan, else
|
|
851
|
+
* `resolved`. Because a fix that shifts lines keeps the same (source|rule|file)
|
|
852
|
+
* key, an unfixed concern can no longer be mis-reported as resolved. Pure.
|
|
853
|
+
*/
|
|
854
|
+
export function partitionByKey(
|
|
855
|
+
requested: { id: string; key: string }[],
|
|
856
|
+
currentKeys: Set<string>,
|
|
857
|
+
): RemediationVerdict {
|
|
858
|
+
const resolved: string[] = [];
|
|
859
|
+
const remaining: string[] = [];
|
|
860
|
+
for (const r of requested) {
|
|
861
|
+
if (currentKeys.has(r.key)) remaining.push(r.id);
|
|
862
|
+
else resolved.push(r.id);
|
|
863
|
+
}
|
|
864
|
+
return { verified: remaining.length === 0, resolved, remaining };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Line-INDEPENDENT regression set: one representative current concern id per KEY
|
|
869
|
+
* present in the re-scan but absent from the pre-fix baseline keys. A pre-existing
|
|
870
|
+
* concern that merely shifted to a new line (same key) is NOT a regression — only
|
|
871
|
+
* a genuinely new (rule, file) defect is. Replaces the raw-id `computeRegressions`
|
|
872
|
+
* for the integrity path. Pure; returns sorted ids for a stable PR body.
|
|
873
|
+
*/
|
|
874
|
+
export function regressionIdsByKey(
|
|
875
|
+
current: { id: string; key: string }[],
|
|
876
|
+
baselineKeys: Set<string>,
|
|
877
|
+
): string[] {
|
|
878
|
+
const seen = new Set<string>();
|
|
879
|
+
const out: string[] = [];
|
|
880
|
+
for (const c of current) {
|
|
881
|
+
if (baselineKeys.has(c.key) || seen.has(c.key)) continue;
|
|
882
|
+
seen.add(c.key);
|
|
883
|
+
out.push(c.id);
|
|
884
|
+
}
|
|
885
|
+
return out.sort();
|
|
886
|
+
}
|
|
@@ -23,6 +23,7 @@ vi.mock("#app/mcp/shared", async (importOriginal) => {
|
|
|
23
23
|
};
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
import { TerraformAssessTool } from "#app/mcp/assess";
|
|
26
27
|
import { _clearProviderSchemaCache } from "#app/mcp/providerSchema";
|
|
27
28
|
import type { ToolContext } from "#app/mcp/server";
|
|
28
29
|
import { changedTerraformFiles } from "#app/mcp/terraform/scanners";
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
TerraformVerifyRemediationTool,
|
|
37
38
|
} from "#app/mcp/terraform/tools";
|
|
38
39
|
import { concernId } from "#app/mcp/terraform/types";
|
|
40
|
+
import { parseToolSelection } from "#app/utils/toolSelection";
|
|
39
41
|
|
|
40
42
|
// --- fake subprocess plumbing ----------------------------------------------
|
|
41
43
|
|
|
@@ -70,7 +72,12 @@ function makeCtx(
|
|
|
70
72
|
over: { payload?: Record<string, unknown>; toolState?: Record<string, unknown> } = {},
|
|
71
73
|
): ToolContext {
|
|
72
74
|
return {
|
|
73
|
-
|
|
75
|
+
// §1.5 — default these tests to the full toolchain (`tools_enabled: all`) so
|
|
76
|
+
// they exercise every scanner; the licence gate's default (non-permissive
|
|
77
|
+
// tools off) is covered explicitly in the gate tests + toolSelection.test.ts.
|
|
78
|
+
// `over.payload` can override (e.g. pass toolsEnabled: undefined to assert the
|
|
79
|
+
// bare default).
|
|
80
|
+
payload: { cwd, toolsEnabled: parseToolSelection("all"), ...(over.payload ?? {}) },
|
|
74
81
|
toolState: { ...(over.toolState ?? {}) },
|
|
75
82
|
tmpdir: makeDir(),
|
|
76
83
|
} as unknown as ToolContext;
|
|
@@ -240,6 +247,59 @@ describe("TerraformScanTool", () => {
|
|
|
240
247
|
);
|
|
241
248
|
});
|
|
242
249
|
|
|
250
|
+
it("§1.5 licence-gates tflint off by default and reports it as a gated skip", async () => {
|
|
251
|
+
const cwd = makeDir({ "main.tf": tf });
|
|
252
|
+
dispatch = scannersDispatch();
|
|
253
|
+
// a bare run (no tools_enabled) → the default licence gate applies.
|
|
254
|
+
const ctx = makeCtx(cwd, { payload: { toolsEnabled: undefined } });
|
|
255
|
+
|
|
256
|
+
const result = await runTool(TerraformScanTool(ctx));
|
|
257
|
+
|
|
258
|
+
// tflint (MPL-2.0) is not run; the permissive scanners still are.
|
|
259
|
+
expect(result.scanners_ran).not.toContain("tflint");
|
|
260
|
+
expect(result.scanners_ran).toEqual(
|
|
261
|
+
expect.arrayContaining(["terraform-fmt", "terraform-validate", "trivy"]),
|
|
262
|
+
);
|
|
263
|
+
// it's surfaced as a licence-gated skip + in the tool_selection summary.
|
|
264
|
+
const tflintSkip = (result.scanners_skipped as Array<{ source: string; reason?: string }>).find(
|
|
265
|
+
(s) => s.source === "tflint",
|
|
266
|
+
);
|
|
267
|
+
expect(tflintSkip?.reason).toMatch(/licence-gated/i);
|
|
268
|
+
expect(result.tool_selection).toMatchObject({
|
|
269
|
+
licence_gated: expect.arrayContaining(["tflint"]),
|
|
270
|
+
});
|
|
271
|
+
// the tflint concern is absent from the baseline, so verify stays consistent.
|
|
272
|
+
expect(ctx.toolState.baselineConcernIds).toHaveLength(3);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("§1.5 runs tflint when it is the licence-aware opt-in in tools_enabled", async () => {
|
|
276
|
+
const cwd = makeDir({ "main.tf": tf });
|
|
277
|
+
dispatch = scannersDispatch();
|
|
278
|
+
const ctx = makeCtx(cwd, { payload: { toolsEnabled: parseToolSelection("tflint") } });
|
|
279
|
+
|
|
280
|
+
const result = await runTool(TerraformScanTool(ctx));
|
|
281
|
+
|
|
282
|
+
expect(result.scanners_ran).toContain("tflint");
|
|
283
|
+
// tflint is no longer gated (it was opted in); terraform_mcp still is.
|
|
284
|
+
const gated = (result.tool_selection as { licence_gated: string[] }).licence_gated;
|
|
285
|
+
expect(gated).not.toContain("tflint");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("§1.5 disables a permissive scanner when tools_enabled vetoes it", async () => {
|
|
289
|
+
const cwd = makeDir({ "main.tf": tf });
|
|
290
|
+
dispatch = scannersDispatch();
|
|
291
|
+
const ctx = makeCtx(cwd, { payload: { toolsEnabled: parseToolSelection("all, -trivy") } });
|
|
292
|
+
|
|
293
|
+
const result = await runTool(TerraformScanTool(ctx));
|
|
294
|
+
|
|
295
|
+
expect(result.scanners_ran).not.toContain("trivy");
|
|
296
|
+
const trivySkip = (result.scanners_skipped as Array<{ source: string; reason?: string }>).find(
|
|
297
|
+
(s) => s.source === "trivy",
|
|
298
|
+
);
|
|
299
|
+
expect(trivySkip?.reason).toMatch(/disabled via tools_enabled/);
|
|
300
|
+
expect(result.tool_selection).toMatchObject({ disabled: expect.arrayContaining(["trivy"]) });
|
|
301
|
+
});
|
|
302
|
+
|
|
243
303
|
it("filters by the explicit severity_threshold but keeps the baseline unfiltered", async () => {
|
|
244
304
|
const cwd = makeDir({ "main.tf": tf });
|
|
245
305
|
dispatch = scannersDispatch();
|
|
@@ -345,6 +405,41 @@ describe("TerraformScanTool", () => {
|
|
|
345
405
|
});
|
|
346
406
|
});
|
|
347
407
|
|
|
408
|
+
describe("TerraformAssessTool — §1.5 toolchain transparency (read-only surface)", () => {
|
|
409
|
+
const tf = 'resource "aws_s3_bucket" "b" {\n bucket = "x"\n}\n';
|
|
410
|
+
|
|
411
|
+
it("surfaces the licence-gated tool in tool_selection on a bare (default-gate) run", async () => {
|
|
412
|
+
const cwd = makeDir({ "main.tf": tf });
|
|
413
|
+
dispatch = scannersDispatch();
|
|
414
|
+
// toolsEnabled: undefined → the default licence gate applies (tflint off).
|
|
415
|
+
const ctx = makeCtx(cwd, { payload: { toolsEnabled: undefined } });
|
|
416
|
+
|
|
417
|
+
const result = await runTool(TerraformAssessTool(ctx));
|
|
418
|
+
|
|
419
|
+
// the assessment still produced a scorecard from the permissive scanners…
|
|
420
|
+
expect(result).toMatchObject({ ok: true, scanned_dir: cwd });
|
|
421
|
+
expect(result.scanners_ran).not.toContain("tflint");
|
|
422
|
+
// …and is honest that tflint was licence-gated off, exactly like terraform_scan.
|
|
423
|
+
expect(result.tool_selection).toMatchObject({
|
|
424
|
+
licence_gated: expect.arrayContaining(["tflint"]),
|
|
425
|
+
disabled: [],
|
|
426
|
+
unknown_tokens: [],
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("surfaces a mistyped tools_enabled token instead of silently ignoring it", async () => {
|
|
431
|
+
const cwd = makeDir({ "main.tf": tf });
|
|
432
|
+
dispatch = scannersDispatch();
|
|
433
|
+
const ctx = makeCtx(cwd, { payload: { toolsEnabled: parseToolSelection("trivy, nope") } });
|
|
434
|
+
|
|
435
|
+
const result = await runTool(TerraformAssessTool(ctx));
|
|
436
|
+
|
|
437
|
+
expect((result.tool_selection as { unknown_tokens: string[] }).unknown_tokens).toEqual([
|
|
438
|
+
"nope",
|
|
439
|
+
]);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
348
443
|
describe("TerraformValidateTool", () => {
|
|
349
444
|
const versionsTf = `terraform {
|
|
350
445
|
required_providers {
|