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/modes.ts
CHANGED
|
@@ -185,7 +185,7 @@ Inline comments use the same severity framing as body \`### \` sections, scaled
|
|
|
185
185
|
// and a reviewer can scan it top-to-bottom in seconds.
|
|
186
186
|
export const REMEDIATION_PR_FORMAT = `### Remediation PR format
|
|
187
187
|
|
|
188
|
-
**Minimum (ALWAYS include, even under tight budget):** a one-paragraph plain-English summary of *what was wrong and what you changed*, then a \`## What changed\` list with one *Was / Changed / Safe because* note per concern, then the \`## Validation
|
|
188
|
+
**Minimum (ALWAYS include, even under tight budget):** a one-paragraph plain-English summary of *what was wrong and what you changed*, then a \`## What changed\` list with one *Was / Changed / Safe because* note per concern, then the \`## Validation\` list. If you produce nothing else, produce these three — a PR a human can't understand from its body alone has failed its job. Everything below enriches this minimum; it does not replace it.
|
|
189
189
|
|
|
190
190
|
Build the PR body in this EXACT order. Every line is backed by a tool result — never write a status you didn't get from a tool. Omit a whole section only when its tool didn't run (e.g. no plan without cloud creds); never fabricate it. Keep a blank line between every block-level element (GitHub needs it to render).
|
|
191
191
|
|
|
@@ -223,19 +223,21 @@ One \`### \` subsection per resolved concern (or one per rule for a by-rule grou
|
|
|
223
223
|
|
|
224
224
|
Lead each heading with a severity emoji (🚨 critical · ⚠️ high · 🔒 security · ℹ️ low/info). Backtick-wrap every identifier. No raw diff dumps — the Files tab shows the diff.
|
|
225
225
|
|
|
226
|
-
#### 4. \`## Validation
|
|
226
|
+
#### 4. \`## Validation\`
|
|
227
227
|
|
|
228
|
-
Built ONLY from \`terraform_verify_remediation\`'s result — this is the proof, not a self-report. One line per id in \`resolved\`, then any still-open id honestly:
|
|
228
|
+
Built ONLY from \`terraform_verify_remediation\`'s result — this is the proof, not a self-report. One ✅ line per id in \`resolved\`, then any still-open id honestly:
|
|
229
229
|
|
|
230
230
|
\`\`\`
|
|
231
|
-
## Validation
|
|
231
|
+
## Validation
|
|
232
232
|
|
|
233
|
-
-
|
|
234
|
-
-
|
|
233
|
+
- ✅ \\\`trivy:AVD-AWS-0088\\\` resolved
|
|
234
|
+
- ✅ \\\`checkov:CKV_AWS_19\\\` resolved
|
|
235
235
|
- ⚠️ still open: \\\`tflint:...\\\` — {why it couldn't be cleared}
|
|
236
236
|
\`\`\`
|
|
237
237
|
|
|
238
|
-
|
|
238
|
+
**Resolved XOR still-open — never both.** Each concern appears on exactly one line: an \`id\` in the tool's \`resolved\` set gets a \`✅ … resolved\` line (the green check means cleared); an \`id\` in \`remaining\` gets a \`⚠️ still open: …\` line. NEVER put a ✅ on an unresolved concern (no \`✅ … re-flagged\`, no \`✅ … still open\`) — that is a false attestation and the single worst thing this body can do. A concern earns its ✅ only if the tool returned its id in \`resolved\`; if it's in \`remaining\`, it is still-open, full stop.
|
|
239
|
+
|
|
240
|
+
If \`has_regressions\` is true, add a \`> [!CAUTION]\` **Regression** callout listing each id in the tool's \`regressions\` set BEFORE this list, and ensure the \`needs-human\` label is set. \`regressions\` are concerns the fix genuinely INTRODUCED (a new \`rule\`+\`file\` not present before) — they are computed line-independently, so a pre-existing concern that merely moved lines is NOT a regression; do not relabel a \`remaining\` concern as a regression.
|
|
239
241
|
|
|
240
242
|
#### 5. \`<details><summary>Plan</summary>\` (when \`terraform_plan\` ran)
|
|
241
243
|
|
|
@@ -635,6 +637,43 @@ ${PR_SUMMARY_FORMAT}`,
|
|
|
635
637
|
${REVIEW_FINDING_PRECEDENTS}
|
|
636
638
|
|
|
637
639
|
${PR_SUMMARY_FORMAT}`,
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
name: "SummarizePr",
|
|
643
|
+
description:
|
|
644
|
+
"Summarize a pull request's changes in a single structured comment — what it does, the key changes, and any areas worth a closer look. Does NOT review, approve, or change code (use Review for a verdict).",
|
|
645
|
+
prompt: `### Checklist
|
|
646
|
+
|
|
647
|
+
This mode posts ONE plain-English summary of what a PR does — an orientation aid, not a verdict. Do NOT approve, request changes, leave inline review comments, or modify any code. If a real review is wanted, that's the Review mode.
|
|
648
|
+
|
|
649
|
+
1. **task list**: create your task list for this run as your first action.
|
|
650
|
+
|
|
651
|
+
2. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. Read the diff TOC so you understand the scope.
|
|
652
|
+
|
|
653
|
+
3. **Terraform anchor (when relevant)**: call \`${t("terraform_change_summary")}\` — it returns the DETERMINISTIC Terraform block changes (resource/module/data/variable/output addresses ADDED and REMOVED, plus the Terraform files touched) vs the base. It degrades green (\`ok: false\`) when git can't resolve the base — run \`${t("git_fetch")}\` on the base ref first and retry — or when the PR has no Terraform changes (then it's a general summary). Use its counts as the factual backbone of the Terraform part of your summary instead of counting by eye.
|
|
654
|
+
|
|
655
|
+
4. **read for intent**: read the \`diffPath\` (and related files as needed) to understand WHAT the PR does and WHY — not just the mechanics. Pull as much context as you need; you are the synthesizer.
|
|
656
|
+
|
|
657
|
+
5. **post the summary**: call \`${t("create_issue_comment")}\` ONCE on the PR with a structured summary in this shape (omit a section when it has nothing):
|
|
658
|
+
|
|
659
|
+
\`\`\`
|
|
660
|
+
## Summary
|
|
661
|
+
{1–2 sentences: what this PR does and why, in plain English.}
|
|
662
|
+
|
|
663
|
+
### Key changes
|
|
664
|
+
- **{short title}** — {one sentence}; backtick-wrap files/identifiers you name.
|
|
665
|
+
- ...
|
|
666
|
+
|
|
667
|
+
### Terraform changes
|
|
668
|
+
{only when terraform_change_summary returned data — e.g. "Adds \\\`module.vpc\\\` and \\\`aws_s3_bucket.logs\\\`; removes \\\`aws_launch_configuration.web\\\`; edits 3 files." Use its real addresses/counts.}
|
|
669
|
+
|
|
670
|
+
### Worth a closer look (optional)
|
|
671
|
+
- {non-blocking observations a reviewer might want to focus on — risk areas, sequencing, things the diff implies but doesn't address. Phrase as orientation, not findings — this is not a review.}
|
|
672
|
+
\`\`\`
|
|
673
|
+
|
|
674
|
+
Keep it scannable: lead with intent, alternate prose with structure, backtick-wrap identifiers, no raw diff dumps, no \`+N/-M\` stats. NEVER fabricate a change — every claim must be in the diff (the Terraform counts come from the tool).
|
|
675
|
+
|
|
676
|
+
6. **finalize**: call \`${t("report_progress")}\` with a one-line note that the summary was posted (or the exact error if the comment failed). Do NOT call \`${t("create_pull_request_review")}\` — this mode summarizes, it does not review.`,
|
|
638
677
|
},
|
|
639
678
|
{
|
|
640
679
|
name: "Plan",
|
|
@@ -704,11 +743,32 @@ ${PR_SUMMARY_FORMAT}`,
|
|
|
704
743
|
- \`git add . && git commit -m "resolve merge conflicts"\`
|
|
705
744
|
- confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
|
|
706
745
|
- Call \`${t("report_progress")}\` with a summary of what was resolved (or the exact push error if push failed)`,
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
name: "Assess",
|
|
749
|
+
description:
|
|
750
|
+
"Read-only Terraform best-practice ASSESSMENT: scan with the deterministic check tools, map findings to compliance controls, and report the posture (clean / advisory / action-required) — without modifying any Terraform or opening a PR.",
|
|
751
|
+
prompt: `### Checklist
|
|
752
|
+
|
|
753
|
+
This mode is **read-only** — the Assess half of Terramend's one-engine-two-modes design. It reports posture; it never fixes. Do NOT edit Terraform, commit, push, or open a PR/issue in this mode. If the findings warrant a fix, say so and recommend re-running in \`remediate\` mode.
|
|
754
|
+
|
|
755
|
+
1. **task list**: create your task list for this run as your first action.
|
|
756
|
+
|
|
757
|
+
2. **assess**: call \`${t("terraform_assess")}\`. It runs the scanners and returns a deterministic \`scorecard\` (overall \`posture\`, \`by_severity\` counts, \`top_risks\`, and an indicative compliance-crosswalk summary) plus a ready-to-post \`markdown\` report. This is the core deliverable — built from tool results, not your own judgement.
|
|
758
|
+
|
|
759
|
+
3. **optional lenses** (fold each into the report only when it actually ran):
|
|
760
|
+
- \`${t("infracost_diff")}\` — current monthly cost (auto-skips without \`INFRACOST_API_KEY\`/the CLI).
|
|
761
|
+
- \`${t("terraform_version_currency")}\` — provider/module pins that are outdated or unpinned.
|
|
762
|
+
- \`${t("terraform_emit_sarif")}\` — when the workflow has a SARIF upload step, emit \`terramend.sarif\` so every concern also lands in the repo's Security tab (read-only, complementary).
|
|
763
|
+
|
|
764
|
+
4. **report**: call \`${t("report_progress")}\` once with the assessment. Use the \`markdown\` from \`${t("terraform_assess")}\` as the base (it carries the posture banner, severity counts, top risks, and the indicative-crosswalk note verbatim — do not soften or inflate it), then append one-line **Cost** / **Version currency** notes if those lenses ran. If \`${t("set_output")}\` is available (standalone runs), also emit the structured result (\`posture\`, \`total\`, \`by_severity\`, the touched \`frameworks\`) so a CI step can gate on \`posture\`.
|
|
765
|
+
|
|
766
|
+
5. **guardrails**: never modify \`*.tf\`/\`*.tfvars\`, never push, never open a PR or issue. The assessment is the only deliverable.`,
|
|
707
767
|
},
|
|
708
768
|
{
|
|
709
769
|
name: "Remediate",
|
|
710
770
|
description:
|
|
711
|
-
"Bring a repository's Terraform up to best practice: scan with the deterministic check tools, then open one scoped, reviewable PR per concern that fixes it and proves
|
|
771
|
+
"Bring a repository's Terraform up to best practice: scan with the deterministic check tools, then open one scoped, reviewable PR per concern that fixes it and proves each fix by re-scanning (✅).",
|
|
712
772
|
prompt: `### Checklist
|
|
713
773
|
|
|
714
774
|
1. **task list**: create your task list for this run as your first action.
|
|
@@ -729,6 +789,8 @@ ${PR_SUMMARY_FORMAT}`,
|
|
|
729
789
|
|
|
730
790
|
**Comment command (§3.12)**: when this run was triggered by a \`@terramend fix …\` comment, the triggering body is in your prompt — honour the requested scope INSTEAD of "highest-severity group": \`fix #<concern-id>\` → act only on the group containing that concern id; \`fix all <severity>-severity\` → set the scan \`severity_threshold\` to that level and act on those groups (up to \`max_prs\`); \`fix <file>.tf\` → act on that file's group; \`fix all\` → act on the highest-severity groups up to \`max_prs\`. If the comment isn't a recognised fix command, fall back to the default scope. A **strategy suffix** — \`fix #<concern-id> with strategy B\` (or a bare \`strategy B\` reply on a proposal thread) — additionally tells you **which** fix to apply: see §26 in step 4.
|
|
731
791
|
|
|
792
|
+
**Bulk remediation (§37 — \`fix rule <rule-id>\` / \`fix all rule <rule-id>\`)**: a request to fix ONE scanner rule everywhere it fires (e.g. \`@terramend fix rule CKV_AWS_23\` — "add a description to every security group", or \`fix rule terraform_required_version\`). Re-scan with \`group_by: "rule"\` (§3.11) so that rule becomes ONE group spanning every file, then act on the single group whose \`rule_ids\` include \`<rule-id>\` — apply the SAME minimal fix at every site in its \`files\` and open ONE coherent PR (not one per file). This is the sweep path; still honour \`max_prs\` and never batch a \`needs-human\` group. Cite each fixed site in the PR body.
|
|
793
|
+
|
|
732
794
|
4. **for the chosen group**:
|
|
733
795
|
- **base branch**: this run's base branch is resolved deterministically — \`${t("create_pull_request")}\` targets the \`base_branch\` input if set, else the repository's default branch (\`main\`, or \`master\`). You do not choose it; just **omit** the \`base\` argument when opening the PR (below) and it is filled in.
|
|
734
796
|
- **idempotency**: the remediation branch is \`remediate/<group-id>\`. Before doing anything, check whether that branch or an open PR for it already exists (\`${t("git")}\` / \`${t("get_pull_request")}\`). If one exists, update it rather than opening a duplicate.
|
|
@@ -736,6 +798,10 @@ ${PR_SUMMARY_FORMAT}`,
|
|
|
736
798
|
- **honest refusal (§29 — decide BEFORE fixing)**: if the group's concerns appear in the scan's \`refusal_candidates\` (the fix needs a human decision — narrowing an IAM wildcard, a KMS key policy, a real ingress CIDR), do **not** guess a fix that could break the stack. Instead open a structured issue (\`${t("create_issue")}\`) describing the concern, why it isn't auto-fixed, and what a human should do, and skip the PR for that group. A proven fix or an honest refusal — never a guessed, unverifiable PR.
|
|
737
799
|
- **propose, then let me steer (§26 — when there's no single right fix)**: distinct from §29 (which refuses a fix a human must *decide*), §26 is for a finding with **2–3 genuinely distinct, defensible fixes** that differ in trade-offs, not correctness (e.g. encrypt with an AWS-managed key **vs** a customer-managed KMS key; a narrow security-group rule **vs** a prefix list **vs** a VPC endpoint). When such a fork exists **and the triggering comment did not already select a strategy**, do **not** silently pick for the reviewer: via \`${t("create_issue_comment")}\` post one short comment listing the options as **A / B / C** — each a single line (what it does + its trade-off) — and ask the reviewer to reply \`@terramend fix #<concern-id> with strategy <A|B|C>\`. Then **skip the PR for this group** this run and note it in your final report (it resumes when the reviewer replies). When the comment **did** select one (\`fix #<id> with strategy B\`, or a bare \`strategy B\` reply on the proposal thread), apply **exactly** that strategy — don't second-guess it. Reserve this for real forks in the road; a fix with one obvious correct answer just gets made.
|
|
738
800
|
- **fix**: edit the group's file(s), using your native file tools. For a by-file group that's the single \`file\`; for a **by-rule group (§3.11)** it's every entry in \`files\` (fix the one rule everywhere it fires). Resolve **every** concern in the group — when the scan's \`co_located\` shows several scanners flagged the same \`file:line\` (§30), they're one underlying defect: write ONE canonical fix and one explanation, not separate edits. **Only touch \`*.tf\` / \`*.tfvars\` files.** Make the smallest changes that clear the concerns — do NOT reformat or refactor unrelated code (see *SYSTEM* surgical-change rules). **Module-source awareness (§4.14):** call \`${t("terraform_module_graph")}\` first — if the concern's file is inside a \`local_module_dir\`, fix it ONCE at the module source (it propagates to all callers; note them in the PR); if the fix would require editing a registry/git/remote module, you can't fix it here — report it (open an issue naming the upstream module + version) instead. **Approved modules (§4.14):** call \`${t("list_modules")}\` and prefer a catalogue module (registry or house, pinned) when the fix is genuinely a module swap — but for a one-line fix on an existing raw resource, fix it in place. **Provider-major awareness (§4.15):** before introducing an argument or block, check \`terraform_validate\`'s \`providers\` list for the pinned \`major\` — argument names and valid blocks differ across majors. After the dir is init-ed (validate/plan ran), you can **verify an argument exists** for the installed provider with \`${t("terraform_provider_schema")}\` (pass the resource type + the arg names you added; it returns any \`unknown_args\` that would break \`plan\`). **Reusing a module?** call \`${t("terraform_module_interface")}\` on its dir to get its real \`variable\` names + which are required, so the \`module\` block you write is correct.
|
|
801
|
+
- **fix QUALITY — a real fix, not a scanner-silencer (enterprise bar)**: the goal is infrastructure that is *actually* safer, not Terraform that merely stops tripping the scanner. Three rules:
|
|
802
|
+
- **Secure defaults, never a hidden-insecure one.** When you parameterise a hardcoded value (§4.13), the new \`variable\`'s \`default\` must be the SECURE choice, or have **no \`default\`** (forcing the operator to set it). NEVER preserve the insecure value as the default — e.g. replacing \`cidr_blocks = ["0.0.0.0/0"]\` with \`var.allowed_cidr_blocks\` *defaulting to \`["0.0.0.0/0"]\`* is not a fix: the deployed behaviour is identical and you've just moved the problem somewhere a scanner may not see it. If the secure value genuinely needs a human decision, this is an honest-refusal (§29) / propose-then-steer (§26) case, not a default-to-insecure.
|
|
803
|
+
- **Optional-input resources must be conditional.** If a fix adds a resource or block that only works when an OPTIONAL input is set (e.g. an HTTPS listener that needs \`var.ssl_certificate_arn\`, which defaults to \`null\`), gate it with \`count\`/\`for_each\` or a \`dynamic\` block so it is NOT emitted — and cannot break \`plan\`/\`apply\` — when the input is unset. Do not write an always-present resource that references a null/empty value, and never claim in the PR that something is "only active when set" unless the HCL actually makes it conditional. Remember \`${t("terraform_validate")}\` can pass on HCL that still fails at \`plan\`/\`apply\` — your claim of conditionality must be in the code, not just the prose.
|
|
804
|
+
- **Modernise, don't perpetuate (§4.15).** Call \`${t("terraform_version_currency")}\` and, when you must add a \`required_providers\`/\`required_version\` block, pin a CURRENT supported major, not an ancient one chosen only to match legacy code. Flag (in the PR body, as a follow-up — not necessarily fixed in this scoped PR) deprecated patterns the scanners don't encode: the archived \`hashicorp/template\` provider + \`data "template_file"\` (modern: the built-in \`templatefile()\` function), \`aws_launch_configuration\` (→ \`aws_launch_template\`), and any provider/module pin that is several majors behind. A best-practice fix should not entrench an EOL provider.
|
|
739
805
|
- **keep the module's tests/examples consistent (§28 — only when you fixed a reusable module)**: if the file(s) you changed live inside a \`local_module_dir\` (from \`${t("terraform_module_graph")}\`) AND your fix changed the module's public interface (added/removed/renamed a \`variable\`, tightened a type), call \`${t("terraform_module_tests")}\` with that module dir. It returns the module's existing \`examples/\` fixtures + \`terraform test\` (\`*.tftest.hcl\`) / Go Terratest files and the \`drift\` per asset — \`missing_required\` (a variable the asset must now set) and \`unknown_set\` (a variable the asset references that no longer exists). Update **exactly** the drifting assets so they match the new interface; **never weaken, delete, or comment out an assertion just to make a test pass** — a fix that breaks a module's contract is the test doing its job, so correct the fix or the fixture, not the assertion. \`examples/\` are \`*.tf\` (always within the push allow-list); native \`*.tftest.hcl\` / Go \`*_test.go\` files are only pushable when the \`terratest\` input is enabled — when it isn't and only those drift, note the needed test update in the PR body for a human rather than leaving the module's own tests broken. Skip this entirely for a one-off raw-resource fix that doesn't touch a module interface.
|
|
740
806
|
- **validate**: call \`${t("terraform_validate")}\`. If it does not pass, fix what it reports or abandon this group — **never open a PR whose validate did not pass**. Its \`providers\` field carries the pinned provider majors (use them as above). It also returns \`unknown_arguments\` (§4.15-next): arguments you wrote that are NOT in the installed provider's schema and would break \`plan\` — treat any entry as a must-fix (correct the argument for the pinned major) even though \`passed\` doesn't gate on it. \`schema_checked: false\` means the schema wasn't available (rely on \`${t("terraform_plan")}\` then).
|
|
741
807
|
- **policy gate (optional, §3.5)**: if the repo ships policy-as-code (a \`policy/\`, \`policies/\`, or \`.conftest\` dir of Rego), call \`${t("policy_check")}\` — it runs \`conftest\` against the plan JSON. It degrades green (\`ok: false\`) when conftest or a policy dir is absent. When it returns \`passed: false\`, treat it exactly like a failed validate: fix the violation (listed in \`failures\`) or label the PR \`needs-human\` and surface it — never push past a policy denial.
|
|
@@ -747,7 +813,7 @@ ${PR_SUMMARY_FORMAT}`,
|
|
|
747
813
|
- **full plan (\`plan_text\`, §1.2)**: when present, attach it to the PR body as a collapsed \`<details><summary>Plan</summary>\\n\\n\\\`\\\`\\\`\\n…\\n\\\`\\\`\\\`\\n</details>\` block so a reviewer can see the exact planned change without re-running it.
|
|
748
814
|
- **commit + push**: \`git add\` only the file you changed, commit with a message naming the file and the key rules (e.g. \`fix(tf): harden main.tf — S3 encryption + block public access\`), then \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*).
|
|
749
815
|
- **open PR — with a COMPLETE body (MANDATORY)**: \`${t("create_pull_request")}\` (omit \`base\` — it resolves to the run's base branch above). The PR body is the primary deliverable a human reviews — open the PR **with a full body**, never a placeholder you intend to fill in later. At an absolute minimum the body MUST explain, in plain English: (a) **what was wrong** — each concern by \`rule_id\` + its \`evidence\`; and (b) **what you changed** to fix each one, and why it's safe. Build it with the **Remediation PR format** at the end of this checklist (status banner → title + badges → \`## What changed\` with the §5.17 *Was / Changed / Safe because* note per concern). ⚠️ \`${t("report_progress")}\` writes the GitHub Actions **job summary**, which is NOT the PR body — a good job summary does **not** substitute for a complete PR body. If you only have time/budget for one, the PR body wins.
|
|
750
|
-
- **prove it (
|
|
816
|
+
- **prove it (re-scan)**: call \`${t("terraform_verify_remediation")}\` with the group's \`concern_ids\`. It re-runs the scanners and returns the authoritative \`resolved\` / \`remaining\` sets and a \`verified\` flag — this is the proof, do NOT eyeball a scan or self-report. Then \`${t("update_pull_request_body")}\` to add a "Validation" section built **from that result**: one \`✅ <rule_id> resolved\` line per id in \`resolved\`, and list every id in \`remaining\` honestly as still-open. Never put a ✅ on a concern unless the tool returned it in \`resolved\`. Act on two more fields it returns:
|
|
751
817
|
- **regressions (§1.4)**: when \`has_regressions\` is true, the fix INTRODUCED new concerns (listed in \`regressions\`) that weren't there before — it traded one defect for another. Add a prominent **⚠️ Regression** callout listing them, add the \`needs-human\` label (\`${t("add_labels")}\`), and prefer reworking the fix to remove the regression before relying on the PR.
|
|
752
818
|
- **confidence (§5.19)**: render the returned \`confidence\` (high/medium/low) as a one-line badge in the PR body (e.g. \`Confidence: high\`) with its \`confidence_reasons\`. It is computed deterministically from the verification evidence (verified + no regressions + plan idempotency + blast radius + cost) — report it verbatim, do NOT inflate it.
|
|
753
819
|
- **per-finding explanation (§5.17)**: in the PR body, give each resolved concern a short three-line note — **Was** (what the scanner flagged, from its \`evidence\`), **Changed** (what your fix did), **Safe because** (why it's correct/non-breaking) — and hyperlink the \`rule_id\` to its documentation. The scan output carries a \`doc_url\` per concern (and \`doc_urls\` per group); use it, falling back to the concern's \`remediation_hint\` when no \`doc_url\` is present.
|
|
@@ -757,7 +823,7 @@ ${PR_SUMMARY_FORMAT}`,
|
|
|
757
823
|
|
|
758
824
|
5. **guardrails** (always): one scoped PR per group, never a mega-PR spanning multiple files; **never auto-merge** and always leave the PR for human review; never modify files outside \`*.tf\` / \`*.tfvars\`.
|
|
759
825
|
|
|
760
|
-
6. **finalize**: call \`${t("report_progress")}\` once with a summary — which file/group was fixed, the PR link, and the
|
|
826
|
+
6. **finalize**: call \`${t("report_progress")}\` once with a summary — which file/group was fixed, the PR link, and the validation result (resolved ✅ / still-open) (or the exact tool error if push/PR creation failed).
|
|
761
827
|
|
|
762
828
|
**SARIF for code-scanning (optional, §3.5)**: when the workflow has a SARIF upload step (it grants \`security-events: write\` and runs \`github/codeql-action/upload-sarif\` on a \`terramend.sarif\`), call \`${t("terraform_emit_sarif")}\` once at the end so the full scan also lands in the repo's Security tab — complementary to the fix PR, not a replacement for it.
|
|
763
829
|
|
|
@@ -824,7 +890,7 @@ ${REMEDIATION_PR_FORMAT}`,
|
|
|
824
890
|
|
|
825
891
|
8. **finalize**:
|
|
826
892
|
- confirm a clean working tree (only your new \`*.tf\`/\`*.tfvars\` files), then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*).
|
|
827
|
-
- open a PR via \`${t("create_pull_request")}\` (omit \`base\` — it resolves to the run's base branch above). Use the **Remediation PR format** conventions (the same status banner + badge row + \`## What changed\` shape, and the body-wide rules) ADAPTED for generation: the body states the requirement, what was generated, the key best-practice choices (security defaults, parameters, modules, pinned versions) and any assumptions; the badge row carries \`Plan\`/\`Cost\` when those tools ran; and in place of the \`## Validation
|
|
893
|
+
- open a PR via \`${t("create_pull_request")}\` (omit \`base\` — it resolves to the run's base branch above). Use the **Remediation PR format** conventions (the same status banner + badge row + \`## What changed\` shape, and the body-wide rules) ADAPTED for generation: the body states the requirement, what was generated, the key best-practice choices (security defaults, parameters, modules, pinned versions) and any assumptions; the badge row carries \`Plan\`/\`Cost\` when those tools ran; and in place of the \`## Validation\` proof list put a \`## Validation\` line stating \`terraform_validate\` passed and \`terraform_scan\` is clean (self-scan: 0 concerns).
|
|
828
894
|
- **never auto-merge** — leave the PR for human review.
|
|
829
895
|
- call \`${t("report_progress")}\` once with the PR link (or the exact tool error if push/PR creation failed).
|
|
830
896
|
|
|
@@ -877,4 +943,8 @@ export const NON_COMMITTING_MODES: ReadonlySet<string> = new Set([
|
|
|
877
943
|
"Review",
|
|
878
944
|
"IncrementalReview",
|
|
879
945
|
"Plan",
|
|
946
|
+
// read-only assessment — reports posture via report_progress, never touches the tree.
|
|
947
|
+
"Assess",
|
|
948
|
+
// §36 — posts a summary COMMENT, never commits to the working tree.
|
|
949
|
+
"SummarizePr",
|
|
880
950
|
]);
|
package/src/toolState.ts
CHANGED
|
@@ -125,6 +125,12 @@ export interface ToolState {
|
|
|
125
125
|
// terraform_verify_remediation to compute §1.4 regressions = current −
|
|
126
126
|
// baseline (concern ids the fix INTRODUCED). undefined until the first scan.
|
|
127
127
|
baselineConcernIds?: string[];
|
|
128
|
+
// §27/enterprise-integrity — the LINE-INDEPENDENT keys (concernKeyOf) of the
|
|
129
|
+
// same full pre-fix baseline. terraform_verify_remediation diffs on these, not
|
|
130
|
+
// raw ids, so a fix that SHIFTS lines (almost every fix) can't make an unfixed
|
|
131
|
+
// concern look resolved nor a pre-existing one look like a regression. Set
|
|
132
|
+
// alongside baselineConcernIds by terraform_scan / read_findings.
|
|
133
|
+
baselineConcernKeys?: string[];
|
|
128
134
|
// the most recent terraform_scan's reported concern set (post scope/severity
|
|
129
135
|
// filtering — what the run acted on). read at end-of-run by
|
|
130
136
|
// finalizeSuccessRun to emit the SARIF artifact + findings-count output
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildModuleFetchGitEnv,
|
|
4
|
+
moduleFetchHosts,
|
|
5
|
+
resolveModuleFetchEnv,
|
|
6
|
+
} from "#app/utils/moduleFetch";
|
|
7
|
+
|
|
8
|
+
describe("buildModuleFetchGitEnv", () => {
|
|
9
|
+
it("injects a Basic auth extraheader per host via GIT_CONFIG_*", () => {
|
|
10
|
+
const env = buildModuleFetchGitEnv("tok123", ["github.com"]);
|
|
11
|
+
expect(env.GIT_CONFIG_COUNT).toBe("1");
|
|
12
|
+
expect(env.GIT_CONFIG_KEY_0).toBe("http.https://github.com/.extraheader");
|
|
13
|
+
const expected = `Authorization: Basic ${Buffer.from("x-access-token:tok123").toString("base64")}`;
|
|
14
|
+
expect(env.GIT_CONFIG_VALUE_0).toBe(expected);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("never embeds the raw token in a key/url (only in the auth header value)", () => {
|
|
18
|
+
const env = buildModuleFetchGitEnv("supersecret", ["github.com"]);
|
|
19
|
+
expect(env.GIT_CONFIG_KEY_0).not.toContain("supersecret");
|
|
20
|
+
// the token is base64'd inside the header, not present verbatim.
|
|
21
|
+
expect(env.GIT_CONFIG_VALUE_0).not.toContain("supersecret");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("emits one entry per host and de-duplicates case-insensitively", () => {
|
|
25
|
+
const env = buildModuleFetchGitEnv("t", ["github.com", "GitHub.com", "ghe.acme.dev"]);
|
|
26
|
+
expect(env.GIT_CONFIG_COUNT).toBe("2");
|
|
27
|
+
expect(env.GIT_CONFIG_KEY_0).toBe("http.https://github.com/.extraheader");
|
|
28
|
+
expect(env.GIT_CONFIG_KEY_1).toBe("http.https://ghe.acme.dev/.extraheader");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns an empty object when no usable host remains", () => {
|
|
32
|
+
expect(buildModuleFetchGitEnv("t", [])).toEqual({});
|
|
33
|
+
expect(buildModuleFetchGitEnv("t", [" "])).toEqual({});
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("moduleFetchHosts", () => {
|
|
38
|
+
it("defaults to github.com", () => {
|
|
39
|
+
expect(moduleFetchHosts(undefined)).toEqual(["github.com"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("adds a GitHub Enterprise Server host from GITHUB_SERVER_URL", () => {
|
|
43
|
+
expect(moduleFetchHosts("https://ghe.acme.dev")).toEqual(["github.com", "ghe.acme.dev"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not duplicate github.com on a github.com-hosted run", () => {
|
|
47
|
+
// GITHUB_SERVER_URL is https://github.com on github.com — the seeded host
|
|
48
|
+
// must not repeat (and is matched case-insensitively).
|
|
49
|
+
expect(moduleFetchHosts("https://github.com")).toEqual(["github.com"]);
|
|
50
|
+
expect(moduleFetchHosts("https://GitHub.com")).toEqual(["github.com"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("ignores a malformed server url", () => {
|
|
54
|
+
expect(moduleFetchHosts("not a url")).toEqual(["github.com"]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("resolveModuleFetchEnv", () => {
|
|
59
|
+
it("returns undefined without a token (the common public/registry case)", () => {
|
|
60
|
+
expect(resolveModuleFetchEnv({})).toBeUndefined();
|
|
61
|
+
expect(resolveModuleFetchEnv({ moduleFetchToken: " " })).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("builds the git env when a token is supplied", () => {
|
|
65
|
+
const env = resolveModuleFetchEnv({ moduleFetchToken: "abc" });
|
|
66
|
+
expect(env?.GIT_CONFIG_KEY_0).toBe("http.https://github.com/.extraheader");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org-private cross-repo module fetch (§1.5 "org-private cross-repo module
|
|
3
|
+
* fetch" — the HepCare shape).
|
|
4
|
+
*
|
|
5
|
+
* Referencing a private module from another repo in your org works today
|
|
6
|
+
* (`git::https://github.com/acme/tf-modules.git//aws/s3?ref=…`), but FETCHING it
|
|
7
|
+
* at `terraform init` does not: the action's own git is locked down (ASKPASS,
|
|
8
|
+
* `credential.helper=`) and the job token is single-repo, so terraform's child
|
|
9
|
+
* `git clone` of the module repo has no credential. This module supplies a
|
|
10
|
+
* scoped one — a PAT / GitHub App token / fine-grained token the operator passes
|
|
11
|
+
* as `module_fetch_token`.
|
|
12
|
+
*
|
|
13
|
+
* Mechanism: Git's `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n`
|
|
14
|
+
* env injection (Git ≥ 2.31). We add an `http.https://<host>/.extraheader`
|
|
15
|
+
* carrying a Basic auth header (the same pattern actions/checkout uses), scoped
|
|
16
|
+
* to the terraform subprocess only — no global/file config is mutated and the
|
|
17
|
+
* token never lands on disk. Only HTTPS `git::` sources are covered; an SSH /
|
|
18
|
+
* deploy-key source needs a key, which is out of scope here.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** the `x-access-token:<token>` Basic header value Git sends per request. */
|
|
22
|
+
function basicAuthHeader(token: string): string {
|
|
23
|
+
const encoded = Buffer.from(`x-access-token:${token}`).toString("base64");
|
|
24
|
+
return `Authorization: Basic ${encoded}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the `GIT_CONFIG_*` env that authorises `git clone` of a private module
|
|
29
|
+
* over HTTPS for each host. Pure (token + hosts in, env out) so it is unit-
|
|
30
|
+
* testable without a real git. Empty/whitespace hosts are dropped; hosts are
|
|
31
|
+
* de-duplicated case-insensitively. Returns an empty object only when no usable
|
|
32
|
+
* host remains (the caller treats that as "no module-fetch credential").
|
|
33
|
+
*/
|
|
34
|
+
export function buildModuleFetchGitEnv(token: string, hosts: string[]): Record<string, string> {
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
const uniqueHosts: string[] = [];
|
|
37
|
+
for (const h of hosts) {
|
|
38
|
+
const host = h.trim().toLowerCase();
|
|
39
|
+
if (!host || seen.has(host)) continue;
|
|
40
|
+
seen.add(host);
|
|
41
|
+
uniqueHosts.push(host);
|
|
42
|
+
}
|
|
43
|
+
if (uniqueHosts.length === 0) return {};
|
|
44
|
+
|
|
45
|
+
const header = basicAuthHeader(token);
|
|
46
|
+
const env: Record<string, string> = { GIT_CONFIG_COUNT: String(uniqueHosts.length) };
|
|
47
|
+
uniqueHosts.forEach((host, i) => {
|
|
48
|
+
env[`GIT_CONFIG_KEY_${i}`] = `http.https://${host}/.extraheader`;
|
|
49
|
+
env[`GIT_CONFIG_VALUE_${i}`] = header;
|
|
50
|
+
});
|
|
51
|
+
return env;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The hosts a module-fetch credential should authorise: always github.com, plus
|
|
56
|
+
* the GitHub host the run executes against (a GitHub Enterprise Server instance
|
|
57
|
+
* via `GITHUB_SERVER_URL`) when it differs. Reads the env; pure given it.
|
|
58
|
+
*/
|
|
59
|
+
export function moduleFetchHosts(serverUrl = process.env.GITHUB_SERVER_URL): string[] {
|
|
60
|
+
const hosts = ["github.com"];
|
|
61
|
+
if (serverUrl) {
|
|
62
|
+
try {
|
|
63
|
+
const host = new URL(serverUrl).hostname;
|
|
64
|
+
// de-dupe case-insensitively: on github.com-hosted runs GITHUB_SERVER_URL
|
|
65
|
+
// is https://github.com, so the seeded host would otherwise repeat.
|
|
66
|
+
if (host && !hosts.some((h) => h.toLowerCase() === host.toLowerCase())) hosts.push(host);
|
|
67
|
+
} catch {
|
|
68
|
+
/* malformed GITHUB_SERVER_URL — fall back to github.com only */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return hosts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the module-fetch git env for a run, or undefined when no
|
|
76
|
+
* `module_fetch_token` was supplied (the common case — public/registry/local
|
|
77
|
+
* modules need no credential). The result is merged into the env of the
|
|
78
|
+
* `terraform init`/`plan` invocations so private cross-repo modules resolve.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveModuleFetchEnv(payload: {
|
|
81
|
+
moduleFetchToken?: string | undefined;
|
|
82
|
+
}): Record<string, string> | undefined {
|
|
83
|
+
const token = payload.moduleFetchToken?.trim();
|
|
84
|
+
if (!token) return undefined;
|
|
85
|
+
return buildModuleFetchGitEnv(token, moduleFetchHosts());
|
|
86
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
2
4
|
import * as core from "@actions/core";
|
|
3
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
6
|
import { BUILTIN_MODE_NAMES } from "#app/modes";
|
|
@@ -399,6 +401,62 @@ describe("resolvePayload — cwd resolution", () => {
|
|
|
399
401
|
});
|
|
400
402
|
});
|
|
401
403
|
|
|
404
|
+
describe("resolvePayload — .terramend.yml layering (input wins, file fills gaps)", () => {
|
|
405
|
+
const dirs: string[] = [];
|
|
406
|
+
/** write a `.terramend.yml` to a temp dir and point GITHUB_WORKSPACE at it. */
|
|
407
|
+
const withConfig = (yml: string): void => {
|
|
408
|
+
const dir = mkdtempSync(join(tmpdir(), "terramend-payload-"));
|
|
409
|
+
dirs.push(dir);
|
|
410
|
+
writeFileSync(join(dir, ".terramend.yml"), yml);
|
|
411
|
+
vi.stubEnv("GITHUB_WORKSPACE", dir);
|
|
412
|
+
};
|
|
413
|
+
afterEach(() => {
|
|
414
|
+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("uses the file value when the matching input is unset", () => {
|
|
418
|
+
withConfig(
|
|
419
|
+
[
|
|
420
|
+
"tools_enabled:",
|
|
421
|
+
" - trivy",
|
|
422
|
+
" - tflint",
|
|
423
|
+
"protected_paths:",
|
|
424
|
+
" - prod/**",
|
|
425
|
+
"scan_scope: diff",
|
|
426
|
+
].join("\n"),
|
|
427
|
+
);
|
|
428
|
+
setInputs({}); // no inputs — the file should fill them
|
|
429
|
+
const p = resolvePayload("p", makeRepoSettings());
|
|
430
|
+
|
|
431
|
+
expect(p.scanScope).toBe("diff");
|
|
432
|
+
expect(p.protectedPaths).toEqual(["prod/**"]);
|
|
433
|
+
// tflint named in the file is the licence-aware opt-in (same as on the input).
|
|
434
|
+
expect(p.toolsEnabled?.explicit.get("tflint")).toBe(true);
|
|
435
|
+
expect(p.toolsEnabled?.explicit.get("trivy")).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("lets an explicit action input win over the file", () => {
|
|
439
|
+
withConfig("scan_scope: diff\nprotected_paths:\n - prod/**");
|
|
440
|
+
setInputs({ scan_scope: "full", protected_paths: "iam/**" });
|
|
441
|
+
const p = resolvePayload("p", makeRepoSettings());
|
|
442
|
+
|
|
443
|
+
expect(p.scanScope).toBe("full"); // input beats the file's "diff"
|
|
444
|
+
expect(p.protectedPaths).toEqual(["iam/**"]); // input beats the file's prod/**
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("is a clean no-op when the repo has no .terramend.yml", () => {
|
|
448
|
+
const dir = mkdtempSync(join(tmpdir(), "terramend-payload-"));
|
|
449
|
+
dirs.push(dir);
|
|
450
|
+
vi.stubEnv("GITHUB_WORKSPACE", dir);
|
|
451
|
+
setInputs({});
|
|
452
|
+
const p = resolvePayload("p", makeRepoSettings());
|
|
453
|
+
|
|
454
|
+
expect(p.scanScope).toBeUndefined();
|
|
455
|
+
expect(p.protectedPaths).toBeUndefined();
|
|
456
|
+
expect(p.toolsEnabled).toBeUndefined();
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
402
460
|
describe("resolvePayload — Terraform remediation inputs", () => {
|
|
403
461
|
it("parses every remediation input through its dedicated parser", () => {
|
|
404
462
|
setInputs({
|
|
@@ -414,6 +472,8 @@ describe("resolvePayload — Terraform remediation inputs", () => {
|
|
|
414
472
|
terratest: "on",
|
|
415
473
|
base_branch: "refs/heads/release/1.2",
|
|
416
474
|
allow_replace: "aws_db_instance.main, aws_s3_bucket.* ,",
|
|
475
|
+
tools_enabled: "all, -trivy",
|
|
476
|
+
module_fetch_token: "ghp_moduletoken",
|
|
417
477
|
});
|
|
418
478
|
|
|
419
479
|
const payload = resolvePayload("p", makeRepoSettings());
|
|
@@ -430,6 +490,11 @@ describe("resolvePayload — Terraform remediation inputs", () => {
|
|
|
430
490
|
expect(payload.terratest).toBe(true);
|
|
431
491
|
expect(payload.baseBranch).toBe("release/1.2");
|
|
432
492
|
expect(payload.allowReplace).toEqual(["aws_db_instance.main", "aws_s3_bucket.*"]);
|
|
493
|
+
// §1.5 — the unified tool-selection list parses into a directive…
|
|
494
|
+
expect(payload.toolsEnabled?.base).toBe("all");
|
|
495
|
+
expect(payload.toolsEnabled?.explicit.get("trivy")).toBe(false);
|
|
496
|
+
// …and the scoped module-fetch token is carried verbatim.
|
|
497
|
+
expect(payload.moduleFetchToken).toBe("ghp_moduletoken");
|
|
433
498
|
});
|
|
434
499
|
|
|
435
500
|
it("degrades invalid remediation inputs to undefined / false", () => {
|
package/src/utils/payload.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { BUILTIN_MODE_NAMES } from "#app/modes";
|
|
|
6
6
|
import { log } from "#app/utils/cli";
|
|
7
7
|
import { parseRemediationCommand } from "#app/utils/remediationCommand";
|
|
8
8
|
import type { RepoSettings } from "#app/utils/runContext";
|
|
9
|
+
import { loadTerramendConfig } from "#app/utils/terramendConfig";
|
|
10
|
+
import { parseToolSelection } from "#app/utils/toolSelection";
|
|
9
11
|
import { validateCompatibility } from "#app/utils/versioning";
|
|
10
12
|
import packageJson from "#package.json" with { type: "json" };
|
|
11
13
|
|
|
@@ -71,6 +73,8 @@ export const Inputs = type({
|
|
|
71
73
|
"module_catalogue?": type.string.or("undefined"),
|
|
72
74
|
"terratest?": type.string.or("undefined"),
|
|
73
75
|
"terraform_mcp?": type.string.or("undefined"),
|
|
76
|
+
"tools_enabled?": type.string.or("undefined"),
|
|
77
|
+
"module_fetch_token?": type.string.or("undefined"),
|
|
74
78
|
"review_instructions?": type.string.or("undefined"),
|
|
75
79
|
"fp_filtering_instructions?": type.string.or("undefined"),
|
|
76
80
|
});
|
|
@@ -133,6 +137,8 @@ function resolveNonPromptInputs() {
|
|
|
133
137
|
module_catalogue: core.getInput("module_catalogue") || undefined,
|
|
134
138
|
terratest: core.getInput("terratest") || undefined,
|
|
135
139
|
terraform_mcp: core.getInput("terraform_mcp") || undefined,
|
|
140
|
+
tools_enabled: core.getInput("tools_enabled") || undefined,
|
|
141
|
+
module_fetch_token: core.getInput("module_fetch_token") || undefined,
|
|
136
142
|
review_instructions: core.getInput("review_instructions") || undefined,
|
|
137
143
|
fp_filtering_instructions: core.getInput("fp_filtering_instructions") || undefined,
|
|
138
144
|
});
|
|
@@ -189,12 +195,14 @@ function parseCostIncreaseBlock(raw: string | undefined): number | undefined {
|
|
|
189
195
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
190
196
|
}
|
|
191
197
|
|
|
192
|
-
/** parse a comma-separated glob list (allowed_paths /
|
|
193
|
-
* undefined when unset or empty after trimming.
|
|
198
|
+
/** parse a comma- or newline-separated glob list (allowed_paths /
|
|
199
|
+
* protected_paths); undefined when unset or empty after trimming. Newlines are
|
|
200
|
+
* accepted so a YAML block scalar in the workflow — and a list in
|
|
201
|
+
* `.terramend.yml` (joined with newlines) — parse the same as a comma list. */
|
|
194
202
|
function parseGlobList(raw: string | undefined): string[] | undefined {
|
|
195
203
|
if (!raw) return undefined;
|
|
196
204
|
const globs = raw
|
|
197
|
-
.split(
|
|
205
|
+
.split(/[\n,]/)
|
|
198
206
|
.map((g) => g.trim())
|
|
199
207
|
.filter(Boolean);
|
|
200
208
|
return globs.length > 0 ? globs : undefined;
|
|
@@ -269,7 +277,14 @@ export function resolvePayload(
|
|
|
269
277
|
resolvedShell = "restricted";
|
|
270
278
|
}
|
|
271
279
|
|
|
272
|
-
|
|
280
|
+
const cwd = resolveCwd(inputs.cwd);
|
|
281
|
+
// §1.5 follow-up — repo-committed `.terramend.yml` policy, layered UNDER the
|
|
282
|
+
// action inputs: a workflow input always wins; the file fills the gaps. Read
|
|
283
|
+
// from the checked-out repo root; a missing file is a silent no-op. See
|
|
284
|
+
// utils/terramendConfig.ts for the trust boundary + supported keys.
|
|
285
|
+
const fileConfig = loadTerramendConfig(cwd);
|
|
286
|
+
|
|
287
|
+
// build payload - precedence: inputs > .terramend.yml > repoSettings > fallbacks
|
|
273
288
|
// note: modes are NOT in payload - they come from repoSettings in main()
|
|
274
289
|
return {
|
|
275
290
|
"~terramend": true as const,
|
|
@@ -288,7 +303,7 @@ export function resolvePayload(
|
|
|
288
303
|
previousRunsNote: jsonPayload?.previousRunsNote,
|
|
289
304
|
event,
|
|
290
305
|
timeout: inputs.timeout ?? jsonPayload?.timeout,
|
|
291
|
-
cwd
|
|
306
|
+
cwd,
|
|
292
307
|
progressComment: jsonPayload?.progressComment,
|
|
293
308
|
generateSummary: jsonPayload?.generateSummary,
|
|
294
309
|
|
|
@@ -299,14 +314,19 @@ export function resolvePayload(
|
|
|
299
314
|
// Terraform remediation config — consumed by mcp/terraform.ts + the
|
|
300
315
|
// Remediate mode. Defaults are applied at the consumer, not here, so
|
|
301
316
|
// "unset" stays distinguishable from an explicit value.
|
|
302
|
-
|
|
303
|
-
|
|
317
|
+
// Terraform policy fields accept a `.terramend.yml` fallback (input wins).
|
|
318
|
+
scanScope: parseScanScope(inputs.scan_scope ?? fileConfig.scan_scope),
|
|
319
|
+
severityThreshold: parseSeverityThreshold(
|
|
320
|
+
inputs.severity_threshold ?? fileConfig.severity_threshold,
|
|
321
|
+
),
|
|
304
322
|
maxPrs: parseMaxPrs(inputs.max_prs),
|
|
305
|
-
allowedPaths: parseGlobList(inputs.allowed_paths),
|
|
323
|
+
allowedPaths: parseGlobList(inputs.allowed_paths ?? fileConfig.allowed_paths),
|
|
306
324
|
// §2.7 — globs the fixer must never auto-modify (inverse of allowed_paths).
|
|
307
|
-
protectedPaths: parseGlobList(inputs.protected_paths),
|
|
325
|
+
protectedPaths: parseGlobList(inputs.protected_paths ?? fileConfig.protected_paths),
|
|
308
326
|
// §3.9 — minimum severity at which a security concern escalates to a human.
|
|
309
|
-
autonomyThreshold: parseSeverityThreshold(
|
|
327
|
+
autonomyThreshold: parseSeverityThreshold(
|
|
328
|
+
inputs.autonomy_threshold ?? fileConfig.autonomy_threshold,
|
|
329
|
+
),
|
|
310
330
|
// §2.8 — opt in to the external gitleaks engine on top of the built-in
|
|
311
331
|
// secret scanner (best-effort; degrades to built-in only when absent).
|
|
312
332
|
gitleaks: parseBooleanInput(inputs.gitleaks),
|
|
@@ -316,7 +336,7 @@ export function resolvePayload(
|
|
|
316
336
|
// §4.14 + module catalogue — operator-approved modules a fix/generation
|
|
317
337
|
// should prefer; raw string, structured by `parseModuleCatalogue` in the
|
|
318
338
|
// `list_modules` tool.
|
|
319
|
-
moduleCatalogue: inputs.module_catalogue,
|
|
339
|
+
moduleCatalogue: inputs.module_catalogue ?? fileConfig.module_catalogue,
|
|
320
340
|
// §28 — opt in to scaffolding a Go Terratest smoke test + a native
|
|
321
341
|
// `*.tftest.hcl` (both plan the module directly) when generating a reusable
|
|
322
342
|
// module; also widens the push allow-list so the test files can be written.
|
|
@@ -326,6 +346,14 @@ export function resolvePayload(
|
|
|
326
346
|
// and provider argument shapes). Requires docker on the runner; degrades
|
|
327
347
|
// green with a note when absent. See utils/terraformMcp.ts.
|
|
328
348
|
terraformMcp: parseBooleanInput(inputs.terraform_mcp),
|
|
349
|
+
// §1.5 — the unified tool-selection allow/deny list. Parsed once here into a
|
|
350
|
+
// ToolDirective; resolveToolSelection(payload) combines it with the dedicated
|
|
351
|
+
// booleans + the licence gate so every consumer agrees on which tools run.
|
|
352
|
+
toolsEnabled: parseToolSelection(inputs.tools_enabled ?? fileConfig.tools_enabled),
|
|
353
|
+
// §1.5 — scoped credential to FETCH private cross-repo `git::` modules at
|
|
354
|
+
// terraform init/plan (the HepCare shape). Injected per-subprocess via
|
|
355
|
+
// GIT_CONFIG_* by resolveModuleFetchEnv; never the action's own git.
|
|
356
|
+
moduleFetchToken: inputs.module_fetch_token,
|
|
329
357
|
// §3.12 — a `@terramend fix …` command parsed from the triggering comment
|
|
330
358
|
// body (the prompt), scoping the run to a specific concern/severity/file.
|
|
331
359
|
// null when the prompt isn't a recognised command.
|
|
@@ -95,6 +95,38 @@ describe("parseRemediationCommand (§3.12)", () => {
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
describe("parseRemediationCommand — bulk remediation (§37)", () => {
|
|
99
|
+
it("parses `fix rule <rule-id>` into a rule command, preserving case", () => {
|
|
100
|
+
expect(parseRemediationCommand("@terramend fix rule CKV_AWS_23")).toEqual({
|
|
101
|
+
kind: "rule",
|
|
102
|
+
ruleId: "CKV_AWS_23",
|
|
103
|
+
});
|
|
104
|
+
expect(parseRemediationCommand("@terramend fix rule terraform_required_version")).toEqual({
|
|
105
|
+
kind: "rule",
|
|
106
|
+
ruleId: "terraform_required_version",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("parses `fix all rule <rule-id>` and namespaced rule ids", () => {
|
|
111
|
+
expect(parseRemediationCommand("hey @terramend fix all rule CKV2_AWS_6 please")).toEqual({
|
|
112
|
+
kind: "rule",
|
|
113
|
+
ruleId: "CKV2_AWS_6",
|
|
114
|
+
});
|
|
115
|
+
expect(parseRemediationCommand("@terramend fix rule trivy:AVD-AWS-0130")).toEqual({
|
|
116
|
+
kind: "rule",
|
|
117
|
+
ruleId: "trivy:AVD-AWS-0130",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("does not mistake the `rule` sweep for a severity / file / concern command", () => {
|
|
122
|
+
// a rule id is not hex, not a severity word, not a *.tf file.
|
|
123
|
+
const cmd = parseRemediationCommand("@terramend fix rule CKV_AWS_8");
|
|
124
|
+
expect(cmd).toEqual({ kind: "rule", ruleId: "CKV_AWS_8" });
|
|
125
|
+
// prose 'the rule about X' has a word between fix and rule → not a command.
|
|
126
|
+
expect(parseRemediationCommand("@terramend please fix the rule about tags")).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
98
130
|
describe("parseRemediationCommand — strategy selection (§26)", () => {
|
|
99
131
|
it("attaches a strategy label to a `fix #<id>` command (letter, upper-normalised)", () => {
|
|
100
132
|
expect(parseRemediationCommand("@terramend fix #3a9f1c2 with strategy B")).toEqual({
|
|
@@ -24,6 +24,9 @@ export type RemediationCommand =
|
|
|
24
24
|
| { kind: "concern"; concernRef: string; strategy?: string }
|
|
25
25
|
| { kind: "severity"; severity: Severity }
|
|
26
26
|
| { kind: "file"; file: string }
|
|
27
|
+
// §37 bulk remediation — sweep ONE scanner rule across every file it fires in
|
|
28
|
+
// (one coherent PR via by-rule grouping). e.g. `@terramend fix rule CKV_AWS_23`.
|
|
29
|
+
| { kind: "rule"; ruleId: string }
|
|
27
30
|
// §26 — a bare strategy pick (e.g. an in-thread reply to a proposal); the
|
|
28
31
|
// concern is resolved from the comment thread the run was triggered on.
|
|
29
32
|
| { kind: "strategy"; strategy: string }
|
|
@@ -71,6 +74,14 @@ export function parseRemediationCommand(body: string | undefined): RemediationCo
|
|
|
71
74
|
const afterMention = body.slice(body.search(MENTION));
|
|
72
75
|
const isSeverity = (s: string): s is Severity => (SEVERITIES as readonly string[]).includes(s);
|
|
73
76
|
|
|
77
|
+
// §37 bulk — `fix rule <rule-id>` / `fix all rule <rule-id>`. Checked FIRST and
|
|
78
|
+
// gated on the explicit `rule` keyword so a scanner rule id (`CKV_AWS_23`,
|
|
79
|
+
// `terraform_required_version`, `trivy:AVD-AWS-0088`) is never confused with a
|
|
80
|
+
// severity word, a filename, or a hex concern id. Rule ids are case-significant
|
|
81
|
+
// — kept verbatim. The fix sweeps that ONE rule across every file it fires in.
|
|
82
|
+
const rule = afterMention.match(/\bfix\s+(?:all\s+)?rule\s+([A-Za-z][A-Za-z0-9_.:-]+)\b/i);
|
|
83
|
+
if (rule) return { kind: "rule", ruleId: rule[1]! };
|
|
84
|
+
|
|
74
85
|
// `fix all <sev>[-severity]` / `fix all` — but a NON-severity word after "all"
|
|
75
86
|
// (prose like "fix all the bugs") is NOT the command: fall through rather than
|
|
76
87
|
// silently treating it as "fix everything".
|