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/dist/modes.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export interface Mode {
|
|
|
5
5
|
prompt?: string | undefined;
|
|
6
6
|
}
|
|
7
7
|
export declare const PR_SUMMARY_FORMAT = "### Default format\n\nThe body has at most four parts in this exact order:\n\n1. **Reviewed changes preamble** \u2014 one bolded inline lead-in describing what was reviewed in this run, a bullet list of the substantive changes, and an HTML comment carrying review metadata for downstream agents.\n2. **Cross-cutting issue sections** (zero or more) \u2014 one `### ` heading per concern, with a human-readable problem write-up and a collapsed `<details>Technical details</details>` block underneath.\n3. **`### \u2139\uFE0F Nitpicks`** (only if there are nits worth surfacing in the body) \u2014 a flat bullet list, no technical-details block.\n4. **`Suppressed findings` collapsed block** at the very bottom (only when the adversarial verification pass suppressed at least one \uD83D\uDEA8/\u26A0\uFE0F candidate) \u2014 the one-line-per-finding audit trail for the false-positive filter.\n\nInline-vs-body split: concerns that anchor to a specific line go inline (use the `comments` parameter). Body `### ` sections are reserved for concerns that **have no line to anchor to** \u2014 typically because the concern is about *absence* (something the diff should have done but didn't), *sequencing* (rollout / deletion / migration order), *design decisions only the human can make*, or *scope questions the diff implicitly raises but doesn't address*. A concern that anchors to a line but has broad implications still goes inline (use the technical-details block there to capture the implications \u2014 see Inline technical details below). If you found no non-anchorable concerns, the body has zero `### ` issue sections \u2014 just the preamble + metadata.\n\n## 1. Reviewed changes preamble\n\nOpen with a single bolded inline lead-in followed immediately by the bullet list (no `### Key changes` heading, no `<b>TL;DR</b>`):\n\n```\n**Reviewed changes** \u2014 one sentence on what was reviewed in this run. For Review (initial), this is what the PR does and why. For IncrementalReview, this is what changed since the prior terramend review. Focus on intent, not mechanics.\n\n- **Short human-readable title** \u2014 1 sentence per substantive change. Write a short prose phrase; when you name a file, type, or function, put that name in backticks (e.g. **Add \\`TodoTracker\\` for live checklists**). A reviewer should understand the full reviewed scope from this list alone \u2014 this IS the dispassionate \"what was reviewed and what changed\" overview, so cover the substantive changes, not just the loudest ones.\n\n<!--\nTerramend review metadata \u2014 for any agent (or human-with-agent) reading this\nreview. Incorporate the fields below into your understanding of the context\nthis review was made in. The findings below were written against\n{head_sha_short}; if new commits have landed on {head_ref} since this review\nwas submitted, treat any specific bug, file, or line callout as POTENTIALLY\nSTALE \u2014 re-diff against {head_sha_short} (or trigger a fresh review) and\nfactor commits past {head_sha_short} into your understanding of the current\nstate before acting on findings.\n\n- Mode: Review (initial) or IncrementalReview (delta against prior terramend review)\n- Files reviewed: {file_count}\n- Commits reviewed: {commit_count}\n- Base: {base_ref} ({base_sha_short})\n- Head: {head_ref} ({head_sha_short})\n- Reviewed commits:\n - {sha_short} \u2014 {commit_subject}\n - ...\n- Prior terramend review: none or {prior_sha_short} ({prior_review_html_url})\n- Submitted at: {iso_timestamp}\n-->\n```\n\nPull every metadata field from the `checkout_pr` tool's response \u2014 file count, commit count, base/head ref + SHA, the commit list. For `IncrementalReview` runs, populate `Prior terramend review` with the prior review's commit_id (short SHA) and `html_url` from `list_pull_request_reviews`.\n\n## 2. Cross-cutting issue sections (zero or more)\n\nFor each cross-cutting concern, one `### ` section. Use this exact shape:\n\n```\n### {emoji} {short, descriptive title \u2014 what's wrong, not what to do}\n\n{Human-readable problem write-up. Describes the PROBLEM only \u2014 what's broken, what the symptom is, what the blast radius is. NO asks, NO suggested fixes, NO \"the right thing to do is...\". Asks and fixes live in the technical-details block below; the visible part is for the human to *understand* the problem, not to implement it.}\n\n<details><summary>Technical details</summary>\n\n\\`\\`\\`\\`markdown\n# {title repeated}\n\n## Affected sites\n- {file path:line} \u2014 {what's wrong there}\n- ...\n\n## Required outcome\n- {what the fix needs to achieve, not how to achieve it}\n- ...\n\n## Suggested approach (optional)\n{When the fix shape is non-obvious, sketch one or more reasonable directions. Skip when the outcome alone makes the fix obvious.}\n\n## Open questions for the human (optional)\n- {Any decision an implementing agent shouldn't make unilaterally \u2014 pricing thresholds, breaking-change policy, naming, scope of follow-up.}\n\\`\\`\\`\\`\n\n</details>\n```\n\nConcrete example of the visible part of a non-anchored section (technical-details block unchanged from the template above):\n\n```\n### \u2139\uFE0F Legacy `opencode.ts` has no documented deletion plan\n\nThe v2 harness lands alongside the v1 file and imports one helper from it. Worth a follow-up issue or a TODO so the next maintainer doesn't have to re-derive the cleanup plan.\n```\n\nThe example's value is its *shape*: a finding about absence (no deletion plan), not a line-anchored bug. Body sections live or die on whether the concern genuinely doesn't fit on a line.\n\n**Heading severity emoji** \u2014 every `### ` heading carries one:\n\n- \uD83D\uDEA8 critical \u2014 blocks merge (data loss, security, broken core flow)\n- \u26A0\uFE0F important \u2014 must address before merging (regression, missing validation, incorrect behavior)\n- \u2139\uFE0F informational \u2014 surfaced for awareness; mergeable as-is\n\n**Visible problem write-up rules:**\n\n- **No asks, no suggested fixes** in the visible part. The visible portion describes the problem; the technical-details block describes the fix shape and any open questions. The exception: a fix so self-evident that NOT stating it would be weird (e.g. \"the typo is missing an 'r'\") \u2014 in that case, fold it into the problem statement and skip the suggested-approach block in technical details too.\n- **Never two successive plain paragraphs.** Every transition between block-level elements must alternate prose with structure: paragraph \u2192 bullet list \u2192 paragraph; paragraph \u2192 code fence \u2192 bullet list; paragraph \u2192 table \u2192 paragraph. Two consecutive paragraphs in a row create a wall of text that's impossible to digest. If you catch yourself writing one, find a way to split it: pull a list out of it, drop a 2-3 line code fence between them, or merge them into a single tighter paragraph.\n- **Per-paragraph budget:** ~3 sentences max. Past that, you're explaining where you should be structuring.\n- **Identifier discipline still applies** in the visible part. Lead with behavior in plain English; name an identifier only when it's the subject of the concern or a public surface a reader would recognize. The technical-details block is where dense identifier references belong.\n\n**Technical-details block rules:**\n\n- Wrapped in a 4-backtick markdown fence (`\\`\\`\\`\\`markdown ... \\`\\`\\`\\``) so it's visually distinct, one-click copyable, and can contain its own 3-backtick code fences without escape gymnastics. The contents are agent-readable \u2014 a fix-agent will pull the body down and use this block as the brief.\n- File paths and `file:line` refs are encouraged (and necessary) \u2014 the next agent uses these to navigate. Identifier density is fine here.\n- Slightly more verbose than the absolute minimum is OK when it materially helps the next agent: a small code snippet showing the symptom, a short table of mismatched key/column pairs, a one-paragraph \"why CI doesn't catch it\" note. Skip massive regression-test scaffolding or full route rewrites \u2014 the implementing agent writes those.\n- Use the four standard sections (`Affected sites`, `Required outcome`, optional `Suggested approach`, optional `Open questions for the human`). Skip the optional sections when they wouldn't add anything.\n\n## Inline technical details\n\nInline comments are short (~2-3 sentences) by default. When an inline finding has broader implications worth recording for a fix-agent \u2014 e.g. a localized bug whose proper fix requires touching several files, or where the right fix depends on a design decision the human needs to make \u2014 append a collapsed `<details><summary>Technical details</summary>` block to the inline comment's body. Same shape as the body-section technical-details block (4-backtick fenced markdown, `## Affected sites` / `## Required outcome` / optional `## Suggested approach` / optional `## Open questions for the human`).\n\nGitHub renders the same markdown parser in inline comments as in the review body, so the collapsed-details affordance works the same way. The visible part of the inline comment stays scannable; the depth is one click away for any agent that needs it.\n\n## 3. `### \u2139\uFE0F Nitpicks` (optional, last content section)\n\nOnly when there are nits that for some reason can't be inlined. Filepaths in nit text are fine \u2014 these are simple enough that a human or agent reads once and acts. No technical-details block.\n\n```\n### \u2139\uFE0F Nitpicks\n\n- {nit, with file path inline if useful, \u2264 ~200 chars}\n- ...\n```\n\n## 4. `Suppressed findings` (optional, very last)\n\nOnly when the adversarial verification pass (see the checklist) suppressed at least one \uD83D\uDEA8/\u26A0\uFE0F candidate. One collapsed block, always the last element in the body, with the count in the summary line:\n\n```\n<details><summary>\uD83D\uDDD1\uFE0F Suppressed findings (2)</summary>\n\n- \u26A0\uFE0F `networking/main.tf:42` \u2014 claimed the subnet exposes the DB publicly \u2014 refuted: `publicly_accessible = false` at `db.tf:18`.\n- \uD83D\uDEA8 `api/auth.ts:77` \u2014 claimed JWT signature bypass \u2014 refuted: the unverified decode is dev-only, gated by the `NODE_ENV` check two lines above.\n\n</details>\n```\n\nOne bullet per suppressed finding: severity emoji, `file:line`, the claim in a few words, the refutation in a few words. One line each \u2014 the block exists for auditability (a human catching the false-positive filter being wrong), not re-litigation. Omit the block entirely when nothing was suppressed.\n\n## Inline comment shape\n\nInline comments use the same severity framing as body `### ` sections, scaled down for line-anchored use:\n\n- **Lead with a 1-2 sentence problem statement.** The reader is looking at the line in question, so don't restate what the line says \u2014 describe what's wrong with it. Optionally prefix the visible line with a severity emoji (\uD83D\uDEA8 / \u26A0\uFE0F / \u2139\uFE0F) when severity isn't obvious from context.\n- **Optional `<details><summary>Technical details</summary>...</details>` collapsible** for findings whose technical context (longer file:line references, related-code snippets, suggested approach, regression-risk notes) would overwhelm the human-readable lead-in. Same agent-readable purpose, same 4-backtick fence shape, and same 4-section structure as the body's technical-details block \u2014 see *Inline technical details* above. Encouraged whenever the depth helps a downstream fix-agent; don't force one when the inline lead-in already says everything.\n- **Visible portion \u2264 2-3 sentences.** If you find yourself writing more, that's the cue to split the depth into the `Technical details` collapsible.\n\n## Body-wide rules\n\n- **Inline-vs-body discipline (repeated for emphasis):** anything that anchors to a specific line goes inline (with a `<details>Technical details</details>` block when the implications are broad). The body is for non-anchorable concerns only \u2014 absence, sequencing, design decisions, scope questions, architectural risk.\n- **No `### Issues found` heading** above the issue sections \u2014 each `### ` heading IS the issue.\n- **Severity emoji on every `### ` heading** (\uD83D\uDEA8 / \u26A0\uFE0F / \u2139\uFE0F). No emoji on the preamble lead-in or anywhere else.\n- **GitHub block-level rendering**: GitHub's markdown parser requires a blank line between ALL block-level elements (HTML tags like `<br/>`, `<sub>`, `<details>`, `<b>` and markdown syntax like headings, lists, blockquotes, code fences, paragraphs). Without a blank line, GitHub treats following content as a continuation of the HTML block and renders markdown syntax as literal text. ALWAYS separate block-level elements with a blank line.\n- **Backtick-wrap** every variable, identifier, or file name when you mention one (in either visible or technical-details portions).\n- **Don't repeat diff content**, don't include raw `+123 / -45` stats, don't include a changelog section, don't use horizontal rules (`---`).\n- **Pull file/commit counts from `checkout_pr` metadata** \u2014 never count manually.\n- **Legacy headings REMOVED.** Do not use `### Key changes`, `### Issues found`, `<b>TL;DR</b>`, or `<sub><b>Summary</b>`. The new structure subsumes them.";
|
|
8
|
-
export declare const REMEDIATION_PR_FORMAT = "### Remediation PR format\n\n**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
|
|
8
|
+
export declare const REMEDIATION_PR_FORMAT = "### Remediation PR format\n\n**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 \u2014 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.\n\nBuild the PR body in this EXACT order. Every line is backed by a tool result \u2014 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).\n\n#### 1. Status banner (first line)\n\nOne GitHub alert blockquote that sets the reviewer's expectation, picked from the verification evidence:\n\n- `> [!CAUTION]` \u2014 a `needs-human` signal fired: a regression (`has_regressions`), a stateful destroy/replace, a high blast radius, a non-deterministic plan, or a cost escalation. One sentence naming the reason.\n- `> [!WARNING]` \u2014 verified but with a caveat (medium blast radius, a still-`remaining` concern, baseline-unavailable cost).\n- `> [!NOTE]` \u2014 clean: `verified: true`, no regressions, low blast radius. One sentence: what was hardened.\n\n#### 2. Title line + badges\n\nA single bolded sentence naming the file/group and what was fixed, then a one-line badge row built from the tool results (drop any badge whose tool didn't run):\n\n```\n**Hardened \\`main.tf\\` \u2014 S3 encryption + public-access block.**\n\n`Confidence: high` \u00B7 `Blast radius: low (1 resource)` \u00B7 `Plan: +0 ~1 -0` \u00B7 `Idempotent: yes` \u00B7 `Cost: +$0.00/mo`\n```\n\nRender `Confidence` verbatim from `terraform_verify_remediation.confidence` \u2014 never inflate it. Use `\u00B7` separators, backtick-wrap each badge.\n\n#### 3. `## What changed`\n\nOne `### ` subsection per resolved concern (or one per rule for a by-rule group), each with the \u00A75.17 three-line micro-template and the rule linked to its docs (`doc_url`, else `remediation_hint`):\n\n```\n### \uD83D\uDD12 [\\`trivy:AVD-AWS-0088\\`](https://avd.aquasec.com/misconfig/avd-aws-0088) \u2014 S3 bucket not encrypted\n\n- **Was** \u2014 {the scanner's `evidence`, in plain English}.\n- **Changed** \u2014 {what the fix did, one sentence}.\n- **Safe because** \u2014 {why it's correct and non-breaking}.\n```\n\nLead each heading with a severity emoji (\uD83D\uDEA8 critical \u00B7 \u26A0\uFE0F high \u00B7 \uD83D\uDD12 security \u00B7 \u2139\uFE0F low/info). Backtick-wrap every identifier. No raw diff dumps \u2014 the Files tab shows the diff.\n\n#### 4. `## Validation`\n\nBuilt ONLY from `terraform_verify_remediation`'s result \u2014 this is the proof, not a self-report. One \u2705 line per id in `resolved`, then any still-open id honestly:\n\n```\n## Validation\n\n- \u2705 \\`trivy:AVD-AWS-0088\\` resolved\n- \u2705 \\`checkov:CKV_AWS_19\\` resolved\n- \u26A0\uFE0F still open: \\`tflint:...\\` \u2014 {why it couldn't be cleared}\n```\n\n**Resolved XOR still-open \u2014 never both.** Each concern appears on exactly one line: an `id` in the tool's `resolved` set gets a `\u2705 \u2026 resolved` line (the green check means cleared); an `id` in `remaining` gets a `\u26A0\uFE0F still open: \u2026` line. NEVER put a \u2705 on an unresolved concern (no `\u2705 \u2026 re-flagged`, no `\u2705 \u2026 still open`) \u2014 that is a false attestation and the single worst thing this body can do. A concern earns its \u2705 only if the tool returned its id in `resolved`; if it's in `remaining`, it is still-open, full stop.\n\nIf `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) \u2014 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.\n\n#### 5. `<details><summary>Plan</summary>` (when `terraform_plan` ran)\n\nAttach the full `plan_text` in a collapsed code block so a reviewer sees the exact change without re-running it:\n\n```\n<details><summary>Terraform plan</summary>\n\n\\`\\`\\`\n{plan_text}\n\\`\\`\\`\n\n</details>\n```\n\nWhen `needs_human` is true, surface `needs_human_reasons` as a visible bullet list above the `<details>` \u2014 don't bury an escalation in a collapsed block.\n\n#### 6. `## \uD83D\uDEE1\uFE0F Prevent recurrence` (optional follow-up)\n\nFrom the scan's `prevention` map \u2014 the CI guardrail that stops this class of concern coming back. Clearly marked **not part of this PR's diff**: a short intro sentence then the `mechanism` + a fenced `snippet`. One entry per distinct rule.\n\n#### 7. `## Compliance` (optional, when a crosswalk was run)\n\nWhen `terraform_compliance_crosswalk` was called, add a short auditor-facing note: the frameworks/controls this fix touches (from `by_framework`), prefixed \"Indicative alignment (crosswalk v{version}) \u2014 not an audit verdict.\" Skip entirely when the crosswalk wasn't run.\n\n#### Body-wide rules\n\n- **Evidence-built, never self-reported** \u2014 every badge, \u2713, and count comes from a tool result. If a tool didn't run, omit its section; don't guess.\n- **Blank line between ALL block-level elements** (callouts, headings, lists, code fences, `<details>`) \u2014 GitHub renders markdown as literal text otherwise.\n- **Backtick-wrap** every file, rule id, resource address, and identifier.\n- **No raw `+N/-M` diff stats, no horizontal rules (`---`), no changelog section.** The footer is appended automatically \u2014 don't add your own.\n- **One scoped group per PR.** The body describes this group's fix only.";
|
|
9
9
|
export declare function computeModes(agentId: AgentId): Mode[];
|
|
10
10
|
export declare const modes: Mode[];
|
|
11
11
|
/** built-in mode names in canonical casing. used to validate / canonicalize the
|
package/dist/toolState.d.ts
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
* Build the `GIT_CONFIG_*` env that authorises `git clone` of a private module
|
|
22
|
+
* over HTTPS for each host. Pure (token + hosts in, env out) so it is unit-
|
|
23
|
+
* testable without a real git. Empty/whitespace hosts are dropped; hosts are
|
|
24
|
+
* de-duplicated case-insensitively. Returns an empty object only when no usable
|
|
25
|
+
* host remains (the caller treats that as "no module-fetch credential").
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildModuleFetchGitEnv(token: string, hosts: string[]): Record<string, string>;
|
|
28
|
+
/**
|
|
29
|
+
* The hosts a module-fetch credential should authorise: always github.com, plus
|
|
30
|
+
* the GitHub host the run executes against (a GitHub Enterprise Server instance
|
|
31
|
+
* via `GITHUB_SERVER_URL`) when it differs. Reads the env; pure given it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function moduleFetchHosts(serverUrl?: string | undefined): string[];
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the module-fetch git env for a run, or undefined when no
|
|
36
|
+
* `module_fetch_token` was supplied (the common case — public/registry/local
|
|
37
|
+
* modules need no credential). The result is merged into the env of the
|
|
38
|
+
* `terraform init`/`plan` invocations so private cross-repo modules resolve.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveModuleFetchEnv(payload: {
|
|
41
|
+
moduleFetchToken?: string | undefined;
|
|
42
|
+
}): Record<string, string> | undefined;
|
package/dist/utils/payload.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export declare const Inputs: import("arktype/internal/variants/object.ts").Objec
|
|
|
38
38
|
module_catalogue?: string | undefined;
|
|
39
39
|
terratest?: string | undefined;
|
|
40
40
|
terraform_mcp?: string | undefined;
|
|
41
|
+
tools_enabled?: string | undefined;
|
|
42
|
+
module_fetch_token?: string | undefined;
|
|
41
43
|
review_instructions?: string | undefined;
|
|
42
44
|
fp_filtering_instructions?: string | undefined;
|
|
43
45
|
}, {}>;
|
|
@@ -89,6 +91,8 @@ export declare function resolvePayload(resolvedPromptInput: ResolvedPromptInput,
|
|
|
89
91
|
moduleCatalogue: string | undefined;
|
|
90
92
|
terratest: boolean;
|
|
91
93
|
terraformMcp: boolean;
|
|
94
|
+
toolsEnabled: import("#app/utils/toolSelection").ToolDirective | undefined;
|
|
95
|
+
moduleFetchToken: string | undefined;
|
|
92
96
|
remediationCommand: import("#app/utils/remediationCommand").RemediationCommand | null;
|
|
93
97
|
baseBranch: string | undefined;
|
|
94
98
|
allowReplace: string[] | undefined;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* and no TFE_TOKEN is ever passed.
|
|
15
15
|
* - degrades green: docker absent → a log note, never a failed run.
|
|
16
16
|
*/
|
|
17
|
-
import type
|
|
17
|
+
import { type ToolSelectionFlags } from "#app/utils/toolSelection";
|
|
18
18
|
/** pinned release of hashicorp/terraform-mcp-server. Bump deliberately. */
|
|
19
19
|
export declare const TERRAFORM_MCP_IMAGE = "hashicorp/terraform-mcp-server:0.5.2";
|
|
20
20
|
/** the registry name the server is registered under in agent MCP configs —
|
|
@@ -39,4 +39,4 @@ export declare function _clearDockerProbeCache(): void;
|
|
|
39
39
|
* server (`available`), log the degrade-green note (`docker_missing`), or do
|
|
40
40
|
* nothing (`disabled`).
|
|
41
41
|
*/
|
|
42
|
-
export declare function resolveTerraformMcp(payload:
|
|
42
|
+
export declare function resolveTerraformMcp(payload: ToolSelectionFlags): TerraformMcpResolution;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-committed `.terramend.yml` config (the §1.5 follow-up to the unified
|
|
3
|
+
* `tools_enabled` input). A thin layer that sits **under** the action inputs: an
|
|
4
|
+
* explicit workflow input always wins; the file only fills the gaps. It versions
|
|
5
|
+
* the toolchain + scoping policy *with the code* (and doubles as the auditable
|
|
6
|
+
* record of which non-permissive tools the repo owner opted into), so the
|
|
7
|
+
* workflow file can stay minimal and the policy lives next to the Terraform.
|
|
8
|
+
*
|
|
9
|
+
* Scope is deliberate — only repo-level **policy** knobs live here. Each maps
|
|
10
|
+
* 1:1 to the matching action input and flows through the SAME parser, so the file
|
|
11
|
+
* and the input validate identically. Secrets (`module_fetch_token`) and
|
|
12
|
+
* per-run/workflow knobs (`mode`, `max_prs`, `base_branch`, `allow_replace`) are
|
|
13
|
+
* intentionally NOT read from the file: a committed file is the wrong place for a
|
|
14
|
+
* credential, and the run's shape belongs to the workflow that dispatches it.
|
|
15
|
+
*
|
|
16
|
+
* Trust boundary: `.terramend.yml` is controlled by whoever can push to the repo
|
|
17
|
+
* — the same surface as the Terraform being remediated. It can only RELAX within
|
|
18
|
+
* the licence gate's structure (naming a non-permissive tool is the repo owner's
|
|
19
|
+
* licence acknowledgement, exactly as on the input) and it can never disable the
|
|
20
|
+
* required substrate. A workflow author who needs to *enforce* a policy sets the
|
|
21
|
+
* action input, which wins over the file.
|
|
22
|
+
*
|
|
23
|
+
* Degrade-green: a missing file is silent; malformed YAML, a non-mapping
|
|
24
|
+
* document, an unknown key, or a value of the wrong shape yields a warning and is
|
|
25
|
+
* ignored — never a hard failure.
|
|
26
|
+
*/
|
|
27
|
+
/** filenames checked, in order; the first that exists wins. */
|
|
28
|
+
export declare const TERRAMEND_CONFIG_FILENAMES: readonly [".terramend.yml", ".terramend.yaml"];
|
|
29
|
+
/** the repo-level keys `.terramend.yml` may set. Each is the snake_case name of
|
|
30
|
+
* the matching action input, so the file value can be fed straight through the
|
|
31
|
+
* input's own parser in `resolvePayload`. */
|
|
32
|
+
export declare const TERRAMEND_CONFIG_KEYS: readonly ["tools_enabled", "protected_paths", "allowed_paths", "scan_scope", "severity_threshold", "autonomy_threshold", "module_catalogue"];
|
|
33
|
+
export type TerramendConfigKey = (typeof TERRAMEND_CONFIG_KEYS)[number];
|
|
34
|
+
/** normalized string values (lists newline-joined), keyed by input name. */
|
|
35
|
+
export type TerramendFileValues = Partial<Record<TerramendConfigKey, string>>;
|
|
36
|
+
export interface ParsedTerramendConfig {
|
|
37
|
+
values: TerramendFileValues;
|
|
38
|
+
/** non-fatal problems (malformed value, unknown key) for the caller to log. */
|
|
39
|
+
warnings: string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parse raw `.terramend.yml` text into normalized string values + warnings.
|
|
43
|
+
* Pure — no I/O, no logging — so the parsing/validation is unit-testable.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseTerramendConfig(raw: string): ParsedTerramendConfig;
|
|
46
|
+
/**
|
|
47
|
+
* Read + parse the repo's `.terramend.yml` (first of the supported filenames
|
|
48
|
+
* found under `cwd`). Returns the normalized values, logging any warnings. A
|
|
49
|
+
* missing file resolves to `{}` silently — the common, valid case.
|
|
50
|
+
*/
|
|
51
|
+
export declare function loadTerramendConfig(cwd: string | undefined): TerramendFileValues;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-licence classification (§5 dependency posture; §1.5 "non-permissive-tool
|
|
3
|
+
* confirmation gate").
|
|
4
|
+
*
|
|
5
|
+
* Terramend ORCHESTRATES external tools — it never bundles or redistributes
|
|
6
|
+
* their binaries (§5 "be an orchestrator, not a redistributor"). This module
|
|
7
|
+
* makes the licence of every selectable tool explicit so the engine can DEFAULT
|
|
8
|
+
* to the permissively-licensed tools and treat a non-permissive one (tflint's
|
|
9
|
+
* embedded BUSL Terraform fork, HashiCorp's terraform-mcp-server) as an
|
|
10
|
+
* *informed, named opt-in* rather than something whose output is consumed
|
|
11
|
+
* silently. Pure data + pure predicates — the gate that consumes it lives in
|
|
12
|
+
* [[toolSelection]].
|
|
13
|
+
*/
|
|
14
|
+
/** every external tool the engine can be configured to run. */
|
|
15
|
+
export type ToolId = "terraform" | "tflint" | "trivy" | "checkov" | "infracost" | "gitleaks" | "conftest" | "terratest" | "terraform_mcp";
|
|
16
|
+
/**
|
|
17
|
+
* Coarse licence family the gate reasons about:
|
|
18
|
+
* - `permissive` — MIT / Apache-2.0 / BSD / ISC: default-enabled.
|
|
19
|
+
* - `copyleft` — MPL / (L)GPL / AGPL: gated (explicit opt-in).
|
|
20
|
+
* - `source-available` — BUSL / SSPL / Elastic: gated (explicit opt-in).
|
|
21
|
+
*/
|
|
22
|
+
export type LicenseClass = "permissive" | "copyleft" | "source-available";
|
|
23
|
+
export interface ToolLicense {
|
|
24
|
+
id: ToolId;
|
|
25
|
+
/** display name for logs / PR notes. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** human-facing SPDX-ish identifier (shown, never parsed). */
|
|
28
|
+
license: string;
|
|
29
|
+
class: LicenseClass;
|
|
30
|
+
/**
|
|
31
|
+
* The core Terraform CLI is the SUBSTRATE the engine cannot run without and
|
|
32
|
+
* which the operator installs themselves — invoking it is never
|
|
33
|
+
* redistribution. It is licence-classified for honesty (Terraform moved to
|
|
34
|
+
* BUSL-1.1 at v1.6) but EXEMPT from the opt-in gate, so a Terraform fixer
|
|
35
|
+
* always has Terraform.
|
|
36
|
+
*/
|
|
37
|
+
required?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The single source of truth for what each tool is licensed under. Verified
|
|
41
|
+
* against the §5 "third-party dependency posture" note (June 2026). Keep in sync
|
|
42
|
+
* when a tool's licence changes (e.g. a vendor relicensing event).
|
|
43
|
+
*/
|
|
44
|
+
export declare const TOOL_LICENSES: Readonly<Record<ToolId, ToolLicense>>;
|
|
45
|
+
export declare const ALL_TOOL_IDS: ToolId[];
|
|
46
|
+
/** a permissively-licensed tool can be enabled by default; anything else needs
|
|
47
|
+
* an explicit, licence-named opt-in. */
|
|
48
|
+
export declare function isPermissive(c: LicenseClass): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* A tool whose output must NOT be consumed without an explicit, licence-named
|
|
51
|
+
* opt-in: non-permissive AND not the required substrate. This is the predicate
|
|
52
|
+
* the confirmation gate is built on.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isLicenseGated(id: ToolId): boolean;
|
|
55
|
+
/** every tool currently behind the licence gate (for docs / reporting). */
|
|
56
|
+
export declare const LICENSE_GATED_TOOLS: ToolId[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified tool-selection config (§1.5 "unified tool-selection config") + the
|
|
3
|
+
* non-permissive-tool confirmation gate (§1.5, built on [[toolLicensing]]).
|
|
4
|
+
*
|
|
5
|
+
* One declarative `tools_enabled` list replaces the previous mix of per-flag
|
|
6
|
+
* inputs and silent presence-detection: an operator names the scanners/engines
|
|
7
|
+
* they want, prefixing `-` to turn one off. The same list is how a non-permissive
|
|
8
|
+
* tool (tflint, terraform-mcp-server) is opted into — naming it is the explicit,
|
|
9
|
+
* licence-aware acknowledgement the gate requires.
|
|
10
|
+
*
|
|
11
|
+
* Resolution precedence, per tool (highest first):
|
|
12
|
+
* 1. the required substrate (`terraform`) → ON (exempt; never disablable)
|
|
13
|
+
* 2. an explicit `-tool` in the list → OFF (always wins over the rest)
|
|
14
|
+
* 3. an explicit `tool` / `+tool` → ON (this is the licence opt-in)
|
|
15
|
+
* 4. `all` base → ON (operator accepts every tool)
|
|
16
|
+
* 5. `none` base → only the explicitly/flag-enabled
|
|
17
|
+
* 6. default base:
|
|
18
|
+
* - licence-gated tool → ON only if its dedicated flag is set, else OFF
|
|
19
|
+
* - flag-opt-in tool → ON only if its dedicated flag is set, else OFF
|
|
20
|
+
* - permissive tool → ON
|
|
21
|
+
*
|
|
22
|
+
* The dedicated booleans (`gitleaks`, `terratest`, `terraform_mcp`) still work
|
|
23
|
+
* and count as an opt-in, so existing workflows keep running unchanged. Pure.
|
|
24
|
+
*/
|
|
25
|
+
import { isPermissive, TOOL_LICENSES, type ToolId } from "#app/utils/toolLicensing";
|
|
26
|
+
/** the parsed `tools_enabled` input: a base posture + per-tool overrides. */
|
|
27
|
+
export interface ToolDirective {
|
|
28
|
+
/** `all` (enable everything) / `none` (enable nothing) / undefined (defaults). */
|
|
29
|
+
base?: "all" | "none" | undefined;
|
|
30
|
+
/** explicit per-tool overrides: true = enable, false = disable. */
|
|
31
|
+
explicit: Map<ToolId, boolean>;
|
|
32
|
+
/** tokens that matched no known tool — surfaced as a warning, never fatal. */
|
|
33
|
+
unknown: string[];
|
|
34
|
+
}
|
|
35
|
+
/** the dedicated booleans that pre-date the unified list. */
|
|
36
|
+
export interface ToolSelectionFlags {
|
|
37
|
+
toolsEnabled?: ToolDirective | undefined;
|
|
38
|
+
gitleaks?: boolean;
|
|
39
|
+
terratest?: boolean;
|
|
40
|
+
terraformMcp?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Parse the `tools_enabled` input (comma- or newline-separated). Recognises the
|
|
44
|
+
* `all` / `none` bases and `tool` / `+tool` / `-tool` (also `!tool`) overrides.
|
|
45
|
+
* Returns undefined for an empty input so "unset" stays distinguishable from an
|
|
46
|
+
* explicit list (the consumer then applies the licence-aware defaults).
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseToolSelection(raw: string | undefined): ToolDirective | undefined;
|
|
49
|
+
/** the resolved selection for a run — a deterministic verdict per tool. */
|
|
50
|
+
export interface ResolvedToolSelection {
|
|
51
|
+
enabled(id: ToolId): boolean;
|
|
52
|
+
/** the reason a tool is OFF (for the scan report / logs); undefined when ON. */
|
|
53
|
+
offReason(id: ToolId): string | undefined;
|
|
54
|
+
/** licence-gated tools that are OFF because they weren't opted into. */
|
|
55
|
+
gated: ToolId[];
|
|
56
|
+
/** tools explicitly turned off via `tools_enabled`. */
|
|
57
|
+
disabled: ToolId[];
|
|
58
|
+
/** unrecognised `tools_enabled` tokens (warn, never fatal). */
|
|
59
|
+
unknownTokens: string[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the per-tool selection from the run's payload (the parsed
|
|
63
|
+
* `tools_enabled` directive + the dedicated booleans). Pure; safe to call from
|
|
64
|
+
* any consumer (the scan tool, the secret guardrail, the terraform-mcp resolver,
|
|
65
|
+
* the plan tool) so they all agree on which tools may run this run.
|
|
66
|
+
*/
|
|
67
|
+
export declare function resolveToolSelection(flags: ToolSelectionFlags): ResolvedToolSelection;
|
|
68
|
+
/** map a `terraform_scan` scanner source to the tool id it belongs to (null for
|
|
69
|
+
* the `reviewer` pseudo-source, which the gate never governs). */
|
|
70
|
+
export declare function scannerToolId(source: string): ToolId | null;
|
|
71
|
+
export type { ToolId };
|
|
72
|
+
export { isPermissive, TOOL_LICENSES };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terramend",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "GitHub Action that remediates Terraform to best practices and opens one scoped pull request per concern.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github-actions",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"docker": "node docker.ts",
|
|
58
58
|
"docs:inputs": "node scripts/generate-input-docs.ts",
|
|
59
59
|
"docs:models": "node scripts/generate-model-docs.ts",
|
|
60
|
+
"eval:propose": "node test/eval/propose.ts",
|
|
60
61
|
"eval:review": "node test/eval/review.ts",
|
|
61
62
|
"eval:scan": "node test/eval/run.ts",
|
|
62
63
|
"format": "biome format --write .",
|
|
@@ -73,21 +74,21 @@
|
|
|
73
74
|
},
|
|
74
75
|
"devDependencies": {
|
|
75
76
|
"@actions/core": "^3.0.1",
|
|
76
|
-
"@anthropic-ai/claude-code": "2.1.
|
|
77
|
+
"@anthropic-ai/claude-code": "2.1.177",
|
|
77
78
|
"@ark/fs": "0.56.0",
|
|
78
79
|
"@ark/util": "0.56.0",
|
|
79
|
-
"@biomejs/biome": "^2.
|
|
80
|
+
"@biomejs/biome": "^2.5.0",
|
|
80
81
|
"@clack/prompts": "^1.5.1",
|
|
81
82
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
82
83
|
"@octokit/plugin-throttling": "^11.0.3",
|
|
83
84
|
"@octokit/rest": "^22.0.1",
|
|
84
85
|
"@octokit/webhooks-types": "^7.6.1",
|
|
85
|
-
"@opencode-ai/sdk": "1.17.
|
|
86
|
+
"@opencode-ai/sdk": "1.17.4",
|
|
86
87
|
"@standard-schema/spec": "1.1.0",
|
|
87
88
|
"@stryker-mutator/core": "^9.6.1",
|
|
88
89
|
"@stryker-mutator/vitest-runner": "^9.6.1",
|
|
89
90
|
"@toon-format/toon": "^2.3.0",
|
|
90
|
-
"@types/node": "^25.9.
|
|
91
|
+
"@types/node": "^25.9.3",
|
|
91
92
|
"@types/semver": "^7.7.1",
|
|
92
93
|
"@types/turndown": "^5.0.6",
|
|
93
94
|
"@vitest/coverage-v8": "^4.1.8",
|
|
@@ -96,14 +97,14 @@
|
|
|
96
97
|
"arkregex": "0.0.5",
|
|
97
98
|
"arktype": "2.2.0",
|
|
98
99
|
"dotenv": "^17.4.2",
|
|
99
|
-
"esbuild": "^0.28.
|
|
100
|
+
"esbuild": "^0.28.1",
|
|
100
101
|
"execa": "^9.6.1",
|
|
101
102
|
"fastmcp": "^4.1.0",
|
|
102
103
|
"file-type": "^22.0.1",
|
|
103
|
-
"opencode-ai": "1.17.
|
|
104
|
+
"opencode-ai": "1.17.4",
|
|
104
105
|
"package-manager-detector": "^1.6.0",
|
|
105
106
|
"picocolors": "^1.1.1",
|
|
106
|
-
"semver": "7.8.
|
|
107
|
+
"semver": "7.8.4",
|
|
107
108
|
"table": "^6.9.0",
|
|
108
109
|
"turndown": "^7.2.4",
|
|
109
110
|
"typescript": "6.0.3",
|
|
@@ -47,13 +47,13 @@ import { SUBAGENT_DENIED_TOOLS } from "#app/agents/subagentToolGates";
|
|
|
47
47
|
* `package.json` — that test fails on any bump, forcing the re-verification
|
|
48
48
|
* before the pin and this constant are updated together.
|
|
49
49
|
*
|
|
50
|
-
* 2.1.
|
|
50
|
+
* 2.1.177 verified 2026-06-10 against the schema embedded in the shipped
|
|
51
51
|
* binary: the base hook input declares `agent_id` as optional with the
|
|
52
52
|
* describe-text "Present only when the hook fires from within a subagent
|
|
53
53
|
* (e.g., a tool called by an AgentTool worker). Absent for the main thread,
|
|
54
54
|
* even in --agent sessions." — exactly the discriminator the gate relies on.
|
|
55
55
|
*/
|
|
56
|
-
export const CLAUDE_CODE_AGENT_ID_VERIFIED_VERSION = "2.1.
|
|
56
|
+
export const CLAUDE_CODE_AGENT_ID_VERIFIED_VERSION = "2.1.177" as const;
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Source written to `<ctx.tmpdir>/terramend-pretool-gate.mjs`. Plain ESM,
|
|
@@ -94,7 +94,7 @@ process.stdin.on("end", () => {
|
|
|
94
94
|
// source). on the orchestrator's main thread agent_id is undefined.
|
|
95
95
|
// agent_type can be set on the orchestrator itself via --agent, so it's
|
|
96
96
|
// not a reliable subagent discriminator on its own; agent_id is.
|
|
97
|
-
// contract verified against @anthropic-ai/claude-code 2.1.
|
|
97
|
+
// contract verified against @anthropic-ai/claude-code 2.1.177 (pinned in
|
|
98
98
|
// package.json; see CLAUDE_CODE_AGENT_ID_VERIFIED_VERSION + the version
|
|
99
99
|
// tripwire in claudePretoolGate.test.ts); re-verify createBaseHookInput if
|
|
100
100
|
// that bumps — if agent_id ever stops being populated the gate fails OPEN.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { assessPosture, buildAssessment, renderAssessmentMarkdown } from "#app/mcp/assess";
|
|
3
|
+
import { buildCrosswalkReport } from "#app/mcp/crosswalk";
|
|
4
|
+
import type { Concern, ScannerOutcome, Severity } from "#app/mcp/terraform/types";
|
|
5
|
+
import { buildVerificationSummary } from "#app/mcp/terraform/verification";
|
|
6
|
+
|
|
7
|
+
/** build a scorecard with the verification summary the tool would attach. An
|
|
8
|
+
* optional `outcomes` lets a test exercise the coverage (verified/inconclusive)
|
|
9
|
+
* view; default [] focuses on the per-concern statuses. */
|
|
10
|
+
function assess(
|
|
11
|
+
concerns: Concern[],
|
|
12
|
+
crosswalk = buildCrosswalkReport(
|
|
13
|
+
concerns.map((c) => ({ id: c.id, rule_id: c.rule_id, evidence: c.evidence })),
|
|
14
|
+
),
|
|
15
|
+
outcomes: ScannerOutcome[] = [],
|
|
16
|
+
) {
|
|
17
|
+
return buildAssessment(concerns, crosswalk, buildVerificationSummary(concerns, outcomes));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let n = 0;
|
|
21
|
+
function concern(severity: Severity, partial: Partial<Concern> = {}): Concern {
|
|
22
|
+
n += 1;
|
|
23
|
+
return {
|
|
24
|
+
id: partial.id ?? `id${n}`,
|
|
25
|
+
source: partial.source ?? "trivy",
|
|
26
|
+
rule_id: partial.rule_id ?? "trivy:AVD-AWS-0001",
|
|
27
|
+
severity,
|
|
28
|
+
category: partial.category ?? "security",
|
|
29
|
+
evidence: partial.evidence ?? "something is wrong",
|
|
30
|
+
location: partial.location ?? { file: "main.tf", line: 1 },
|
|
31
|
+
remediation_hint: partial.remediation_hint ?? null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("assessPosture", () => {
|
|
36
|
+
const zero = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
37
|
+
it("is action-required on any critical/high", () => {
|
|
38
|
+
expect(assessPosture({ ...zero, high: 1 })).toBe("action-required");
|
|
39
|
+
expect(assessPosture({ ...zero, critical: 1 })).toBe("action-required");
|
|
40
|
+
});
|
|
41
|
+
it("is advisory when only medium/low/info", () => {
|
|
42
|
+
expect(assessPosture({ ...zero, medium: 2 })).toBe("advisory");
|
|
43
|
+
expect(assessPosture({ ...zero, info: 1 })).toBe("advisory");
|
|
44
|
+
});
|
|
45
|
+
it("is clean when there are no concerns", () => {
|
|
46
|
+
expect(assessPosture(zero)).toBe("clean");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("buildAssessment", () => {
|
|
51
|
+
it("counts by severity, caps + severity-orders top risks, and summarises the crosswalk", () => {
|
|
52
|
+
const concerns = [
|
|
53
|
+
concern("low", {
|
|
54
|
+
rule_id: "tflint:terraform_required_version",
|
|
55
|
+
evidence: "no required_version",
|
|
56
|
+
}),
|
|
57
|
+
concern("critical", {
|
|
58
|
+
rule_id: "trivy:AVD-AWS-0088",
|
|
59
|
+
evidence: "S3 bucket is unencrypted at rest",
|
|
60
|
+
}),
|
|
61
|
+
concern("high", { rule_id: "checkov:CKV_AWS_260", evidence: "0.0.0.0/0 ingress is public" }),
|
|
62
|
+
];
|
|
63
|
+
const crosswalk = buildCrosswalkReport(
|
|
64
|
+
concerns.map((c) => ({ id: c.id, rule_id: c.rule_id, evidence: c.evidence })),
|
|
65
|
+
);
|
|
66
|
+
const s = assess(concerns, crosswalk);
|
|
67
|
+
|
|
68
|
+
expect(s.posture).toBe("action-required");
|
|
69
|
+
expect(s.total).toBe(3);
|
|
70
|
+
expect(s.by_severity.critical).toBe(1);
|
|
71
|
+
expect(s.by_severity.high).toBe(1);
|
|
72
|
+
expect(s.by_severity.low).toBe(1);
|
|
73
|
+
// top risks are severity-ordered (critical → high → low)
|
|
74
|
+
expect(s.top_risks.map((r) => r.severity)).toEqual(["critical", "high", "low"]);
|
|
75
|
+
// encryption + public-exposure concerns map to controls; the required_version
|
|
76
|
+
// one doesn't → unmapped. frameworks touched is non-empty.
|
|
77
|
+
expect(s.compliance.frameworks.length).toBeGreaterThan(0);
|
|
78
|
+
expect(s.compliance.controls_touched).toBeGreaterThan(0);
|
|
79
|
+
expect(s.compliance.mapped).toBe(2);
|
|
80
|
+
expect(s.compliance.unmapped).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("is clean with an empty crosswalk when there are no concerns", () => {
|
|
84
|
+
const s = assess([]);
|
|
85
|
+
expect(s.posture).toBe("clean");
|
|
86
|
+
expect(s.total).toBe(0);
|
|
87
|
+
expect(s.top_risks).toEqual([]);
|
|
88
|
+
expect(s.compliance.frameworks).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("attaches the five-status verification summary (fail vs not-code-verifiable + coverage)", () => {
|
|
92
|
+
const concerns = [
|
|
93
|
+
concern("critical", { rule_id: "trivy:AVD-AWS-0088", evidence: "S3 unencrypted at rest" }),
|
|
94
|
+
// an IAM least-privilege concern is a human decision → not-code-verifiable.
|
|
95
|
+
concern("high", { rule_id: "checkov:CKV_AWS_1", evidence: "IAM policy uses a wildcard *" }),
|
|
96
|
+
];
|
|
97
|
+
const outcomes: ScannerOutcome[] = [
|
|
98
|
+
{ source: "trivy", ran: true, concerns: [] },
|
|
99
|
+
{ source: "tflint", ran: false, skipped_reason: "licence-gated", concerns: [] },
|
|
100
|
+
];
|
|
101
|
+
const s = assess(concerns, undefined, outcomes);
|
|
102
|
+
expect(s.verification.counts.fail).toBe(1);
|
|
103
|
+
expect(s.verification.counts.not_code_verifiable).toBe(1);
|
|
104
|
+
expect(s.verification.coverage.verified).toContain("trivy");
|
|
105
|
+
expect(s.verification.coverage.inconclusive).toEqual([
|
|
106
|
+
{ source: "tflint", reason: "licence-gated" },
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("renderAssessmentMarkdown", () => {
|
|
112
|
+
it("renders the posture banner, counts, top risks and the indicative-crosswalk note", () => {
|
|
113
|
+
const concerns = [
|
|
114
|
+
concern("critical", { rule_id: "trivy:AVD-AWS-0088", evidence: "unencrypted at rest" }),
|
|
115
|
+
];
|
|
116
|
+
const crosswalk = buildCrosswalkReport(
|
|
117
|
+
concerns.map((c) => ({ id: c.id, rule_id: c.rule_id, evidence: c.evidence })),
|
|
118
|
+
);
|
|
119
|
+
const md = renderAssessmentMarkdown(assess(concerns, crosswalk));
|
|
120
|
+
expect(md).toContain("[!CAUTION]");
|
|
121
|
+
expect(md).toContain("Action required");
|
|
122
|
+
expect(md).toContain("`critical: 1`");
|
|
123
|
+
expect(md).toContain("trivy:AVD-AWS-0088");
|
|
124
|
+
expect(md).toContain("not an audit verdict");
|
|
125
|
+
// honest read-only framing — never claims to have fixed anything.
|
|
126
|
+
expect(md).toContain("no Terraform was modified");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("renders a clean banner with no top-risk section when there are no concerns", () => {
|
|
130
|
+
const md = renderAssessmentMarkdown(assess([]));
|
|
131
|
+
expect(md).toContain("[!NOTE]");
|
|
132
|
+
expect(md).toContain("Clean");
|
|
133
|
+
expect(md).not.toContain("### Top risks");
|
|
134
|
+
});
|
|
135
|
+
});
|