infraweaver 0.3.10 → 0.3.12
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/README.md +1 -1
- package/dist/agents/quotaTokens.d.ts +8 -0
- package/dist/cli.mjs +1929 -1608
- package/dist/index.js +568 -250
- package/dist/internal.js +6 -4
- package/dist/mcp/crosswalk-digest.d.ts +1 -1
- package/dist/mcp/git.d.ts +19 -3
- package/dist/mcp/localContext.d.ts +9 -1
- package/dist/mcp/shell.d.ts +3 -3
- package/dist/mcp/staleFix.d.ts +13 -1
- package/dist/mcp/terraform/awsPrices.d.ts +1 -1
- package/dist/mcp/terraform/azurePrices.d.ts +1 -1
- package/dist/mcp/terraform/hardcodedSecrets.d.ts +49 -0
- package/dist/mcp/terraform/nativeRules.d.ts +1 -0
- package/dist/mcp/terraform/refactorScope.d.ts +12 -0
- package/dist/mcp/terraform/scanSession.d.ts +2 -0
- package/dist/mcp/terraform/tools/scan.d.ts +1 -0
- package/dist/mcp/terraform/types.d.ts +3 -0
- package/dist/mcp/terraform/verifySites.d.ts +48 -0
- package/dist/utils/config/payload.d.ts +2 -0
- package/package.json +1 -1
- package/src/agents/claude.ts +2 -1
- package/src/agents/opencode.ts +2 -1
- package/src/agents/quotaTokens.ts +25 -0
- package/src/mcp/crosswalk-digest.ts +1 -1
- package/src/mcp/crosswalk.ts +2 -0
- package/src/mcp/git.ts +44 -3
- package/src/mcp/localContext.ts +8 -0
- package/src/mcp/localServer.ts +3 -0
- package/src/mcp/moduleExtraction.ts +4 -1
- package/src/mcp/shell.ts +2 -2
- package/src/mcp/staleFix.ts +21 -2
- package/src/mcp/terraform/awsPrices.ts +1 -1
- package/src/mcp/terraform/azurePrices.ts +23 -23
- package/src/mcp/terraform/concernResult.ts +26 -4
- package/src/mcp/terraform/hardcodedSecrets.ts +202 -0
- package/src/mcp/terraform/nativeRuleScanners.ts +8 -0
- package/src/mcp/terraform/nativeRules.ts +14 -0
- package/src/mcp/terraform/refactorScope.ts +86 -0
- package/src/mcp/terraform/scanSession.ts +6 -0
- package/src/mcp/terraform/standardsReport.ts +2 -0
- package/src/mcp/terraform/tools/consolidationCandidates.ts +5 -1
- package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -1
- package/src/mcp/terraform/tools/verifyRemediation.ts +23 -1
- package/src/mcp/terraform/types.ts +3 -0
- package/src/mcp/terraform/verifySites.ts +106 -0
- package/src/modes/refactor.ts +2 -0
- package/src/modes/refresh-remediation.ts +1 -1
- package/src/modes/remediate-and-refactor.ts +1 -1
- package/src/modes/remediate.ts +2 -2
- package/src/utils/config/payload.ts +4 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which findings a fix resolved where its rule still fires elsewhere in the file.
|
|
3
|
+
*
|
|
4
|
+
* terraform_verify_remediation keys a finding on (scanner, rule, file), so a
|
|
5
|
+
* rule fixed on one resource but still firing on another in the same file read
|
|
6
|
+
* as unfixed for both: AWSGoat's open-egress fix on two of four security groups
|
|
7
|
+
* verified as 0 of 5. Counting by resource instead risks the opposite error —
|
|
8
|
+
* a fix that moves the defect onto a new resource would look resolved — so a
|
|
9
|
+
* finding counts as resolved at its own site only when ALL of these hold:
|
|
10
|
+
*
|
|
11
|
+
* - it was placed on a resource when it was reported;
|
|
12
|
+
* - that resource is still declared in the file (a renamed or deleted one
|
|
13
|
+
* proves nothing);
|
|
14
|
+
* - the rule no longer fires on it;
|
|
15
|
+
* - every place the rule still fires in the file is a resource it already
|
|
16
|
+
* fired on before the fix (no new site, and no finding outside a resource).
|
|
17
|
+
*
|
|
18
|
+
* Anything else keeps the file-level answer, which under-claims and never
|
|
19
|
+
* over-claims.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import type { ScanSession } from "#app/mcp/terraform/scanSession";
|
|
25
|
+
import { resourceAddressAt } from "#app/mcp/terraform/suppressions";
|
|
26
|
+
import { type Concern, concernKeyOf } from "#app/mcp/terraform/types";
|
|
27
|
+
|
|
28
|
+
/** A reader that returns each file under `cwd` once, or null when unreadable. */
|
|
29
|
+
export function fileReader(cwd: string): (file: string) => string | null {
|
|
30
|
+
const cache = new Map<string, string | null>();
|
|
31
|
+
return (file) => {
|
|
32
|
+
if (!cache.has(file)) {
|
|
33
|
+
let text: string | null;
|
|
34
|
+
try {
|
|
35
|
+
text = readFileSync(join(cwd, file), "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
text = null;
|
|
38
|
+
}
|
|
39
|
+
cache.set(file, text);
|
|
40
|
+
}
|
|
41
|
+
return cache.get(file) ?? null;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The resource a concern sits in, read from the file as it is now. */
|
|
46
|
+
export function siteOf(c: Concern, read: (file: string) => string | null): string | null {
|
|
47
|
+
const text = read(c.location.file);
|
|
48
|
+
return text === null ? null : resourceAddressAt(text, c.location.line);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Remember where each reported concern sat, and the first sites per key. */
|
|
52
|
+
export function recordConcernSites(session: ScanSession, concerns: Concern[], cwd: string): void {
|
|
53
|
+
const read = fileReader(cwd);
|
|
54
|
+
session.siteById ??= new Map();
|
|
55
|
+
session.firstSitesByKey ??= new Map();
|
|
56
|
+
const first = session.firstSitesByKey;
|
|
57
|
+
const fresh = new Map<string, Set<string | null>>();
|
|
58
|
+
for (const c of concerns) {
|
|
59
|
+
const site = siteOf(c, read);
|
|
60
|
+
session.siteById.set(c.id, site);
|
|
61
|
+
const key = concernKeyOf(c);
|
|
62
|
+
if (first.has(key)) continue;
|
|
63
|
+
const sites = fresh.get(key) ?? new Set();
|
|
64
|
+
sites.add(site);
|
|
65
|
+
fresh.set(key, sites);
|
|
66
|
+
}
|
|
67
|
+
for (const [key, sites] of fresh) first.set(key, sites);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SiteRequest {
|
|
71
|
+
id: string;
|
|
72
|
+
/** the (scanner, rule, file) key */
|
|
73
|
+
key: string;
|
|
74
|
+
/** the resource address the finding sat in when reported, or null */
|
|
75
|
+
site: string | null;
|
|
76
|
+
file: string | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resolvedAtSite(params: {
|
|
80
|
+
requested: readonly SiteRequest[];
|
|
81
|
+
/** every finding the re-scan reports, with the resource it sits in */
|
|
82
|
+
current: readonly { key: string; site: string | null }[];
|
|
83
|
+
/** per key, the resources the rule fired on in the first scan of the run */
|
|
84
|
+
firstSites: ReadonlyMap<string, ReadonlySet<string | null>>;
|
|
85
|
+
/** the resource addresses a file declares now */
|
|
86
|
+
declared: (file: string) => ReadonlySet<string>;
|
|
87
|
+
}): string[] {
|
|
88
|
+
const firingByKey = new Map<string, (string | null)[]>();
|
|
89
|
+
for (const c of params.current) {
|
|
90
|
+
const list = firingByKey.get(c.key) ?? [];
|
|
91
|
+
list.push(c.site);
|
|
92
|
+
firingByKey.set(c.key, list);
|
|
93
|
+
}
|
|
94
|
+
const out: string[] = [];
|
|
95
|
+
for (const r of params.requested) {
|
|
96
|
+
if (r.site === null || r.file === undefined) continue;
|
|
97
|
+
const firing = firingByKey.get(r.key) ?? [];
|
|
98
|
+
const known = params.firstSites.get(r.key);
|
|
99
|
+
if (known === undefined) continue;
|
|
100
|
+
if (firing.some((s) => s === null || !known.has(s))) continue;
|
|
101
|
+
if (firing.includes(r.site)) continue;
|
|
102
|
+
if (!params.declared(r.file).has(r.site)) continue;
|
|
103
|
+
out.push(r.id);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
package/src/modes/refactor.ts
CHANGED
|
@@ -18,6 +18,8 @@ This mode standardises STRUCTURE while preserving BEHAVIOUR — the equivalence
|
|
|
18
18
|
- \`${t("terraform_normalization_candidates")}\` → in-place idiomatic cleanups that change SYNTAX, not behaviour (redundant whole-string \`"\${expr}"\` interpolation; legacy HCL0.11 map-argument block syntax like \`vars {\` on a \`template_file\`). These relocate NOTHING — zero \`moved {}\` blocks — and are proven by the same equivalence check.
|
|
19
19
|
- \`${t("terraform_consolidation_candidates")}\` → the SAME resource shape declared in several root directories (\`env/dev\` + \`env/staging\` + \`env/prod\`), with the single parameterised module they could all call already derived: which attributes are identical (module content) and which differ (per-environment inputs). A member root of a group will ALSO show up as a zero-candidate cluster in \`${t("module_extraction_candidates")}\` — prefer the consolidation, which does that work once for every environment instead of once per environment. Its \`near_groups\` are the opposite case: roots recognisably the same stack that DIFFER (one carries a resource the other does not), where folding them together is a design decision this mode has no basis for. Do NOT extract a near-group root's shared resources into a module on its own either — a module one twin calls and the other does not widens exactly the drift the near-group reports. Leave those roots alone and name the near-group and its \`differences\` in your report.
|
|
20
20
|
|
|
21
|
+
**A result marked \`report_only\` is an observation, not work.** The repository is a module other configurations call (\`report_only_reason\` says why), and restructuring it is churn for every caller: name those candidates in your \`${t("report_progress")}\` summary and open no PR for them. \`examples/\` roots are left out of every candidate list unless the operator set \`refactor_examples\`.
|
|
22
|
+
|
|
21
23
|
**Before picking, check the interface.** \`${t("module_extraction_candidates")}\` reports \`unmapped_attributes\` per candidate and a top-level \`partial_interfaces\` list: arguments the raw resources SET that the target module does not. Adopting such a module DROPS them silently — and the equivalence check cannot catch it, because once the argument is gone it is absent from both sides. Two of these are unrecoverable rather than merely wrong (\`object_lock_enabled\` cannot be set after creation; \`force_destroy\` changes deletion semantics). So: **decline the candidate, or extend the module to expose the argument and say so in the PR body.** Deleting the argument to make the shapes match is never the fix. A candidate whose \`unmapped_attributes\` is \`null\` was NOT checked — the module is external and its code is not in this repo — which is not the same as clean.
|
|
22
24
|
|
|
23
25
|
Pick the highest-value refactor across ALL THREE detectors. Only when they ALL come back empty — or all that remains is a cluster in a near-group root — is there genuinely nothing to do: call \`${t("report_progress")}\` with an ACCURATE message — e.g. "No behaviour-preserving refactor found: no extraction clusters and no idiomatic-normalisation candidates." — and **stop**. Two anti-patterns to avoid in that message: (i) never call a repo "already idiomatic" while normalisation candidates remain; (ii) when what's left is behaviour-CHANGING modernisation — a deprecated-resource swap (\`aws_launch_configuration\`→\`aws_launch_template\`, \`aws_elb\`→\`aws_lb\`, \`template_file\`→\`templatefile()\`) — do NOT report "nothing to refactor": that is real work, but it ALTERS the resource set, so it belongs to Remediate. Say so explicitly rather than implying the code is clean.
|
|
@@ -26,7 +26,7 @@ This mode keeps already-open ${PRODUCT_NAME} remediation PRs healthy. A remediat
|
|
|
26
26
|
- **resolved-on-base? → close it**: look for a group in step 3's scan whose \`id\` equals the PR's \`group_id\` (for a by-rule/batch PR, match on the concern ids it covered). If **no** current group/concern corresponds, the concern was already fixed on the base (a human fix, or a base change removed the file) — the PR is redundant. Call \`${t("close_pull_request")}\` with a one-line \`comment\` explaining it's resolved on the base. Do not push anything for this PR.
|
|
27
27
|
- **still present → re-derive the fix on the current base**:
|
|
28
28
|
- **read WHY the CI failed, before changing anything** (\`checks_failing\` PRs only): when step 2 gave the PR a \`failing_check_suite_id\`, call \`${t("get_check_suite_logs")}\` with that id FIRST. Re-deriving without reading the failure is exactly how attempt 2 and attempt 3 become identical repeats of attempt 1 — same fix, same red check, three times, until the retry cap escalates a PR nobody learned anything about. Use the returned \`failed_steps\` and log excerpt to decide what to do DIFFERENTLY this attempt. If the log shows the failure is not ours — a broken base, a missing credential, an unrelated flaky job — do not spend an attempt re-deriving: add \`needs-human\` (\`${t("add_labels")}\`), post ONE comment naming the failing step, and move on. Record the cause in the PR body/comment you write below, so the next attempt and any human start from what you found. When the id is absent, or the tool reports no failed runs, say so in the comment and continue with the plain re-derive rather than guessing.
|
|
29
|
-
- **branch**: recreate the remediation branch at the current base HEAD via \`${t("git")}\` (\`git checkout -B
|
|
29
|
+
- **branch**: recreate the remediation branch at the current base HEAD via \`${t("git")}\` (\`git({ command: "checkout", args: ["-B", <the PR's branch>] })\`, the \`branch\` step 2 returned) — \`-B\` force-resets it to the just-scanned base so the diff is only your fix.
|
|
30
30
|
- **fix → validate → plan → keep tests consistent → prove it**: apply the minimal fix for that group exactly as in **Remediate** step 4 (same \`${t("terraform_validate")}\`, \`${t("terraform_plan")}\`, \`${t("terraform_module_tests")}\`, and \`${t("terraform_verify_remediation")}\` gates, and the same guardrails — never open/keep a PR whose validate didn't pass, abandon a group that would destroy a stateful resource, etc.).
|
|
31
31
|
- **force-update the PR branch**: \`${t("push_branch")}\` with \`force: true\` (the PR already exists; force-updating its branch refreshes it in place — do NOT open a second PR). The Terraform-only / secret / destroy guardrails still run at push time.
|
|
32
32
|
- **refresh the body**: \`${t("update_pull_request_body")}\` rebuilt from the fresh \`${t("terraform_verify_remediation")}\` result (the Remediation PR format below), and add a one-line note that it was rebased onto the current base (\`<short-sha>\`) on this run.
|
|
@@ -20,7 +20,7 @@ This mode composes the **Remediate** and **Refactor** verbs into a single run. I
|
|
|
20
20
|
- **commit the fix** (\`git add\` only the changed \`*.tf\`/\`*.tfvars\`, a \`fix(tf): …\` message). Do NOT push yet. This committed state is the refactor's equivalence BASELINE.
|
|
21
21
|
|
|
22
22
|
3. **REFACTOR (phase 2) — modularise on top of the committed fix**: follow the **Refactor** mode's flow:
|
|
23
|
-
- \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` → pick one behaviour-preserving refactor. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
|
|
23
|
+
- \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` → pick one behaviour-preserving refactor; a result marked \`report_only\` (the repository is a module others call) is named in the summary, never proposed. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
|
|
24
24
|
- resolve the module source, wire the \`module\` call against its real interface (\`${t("terraform_module_interface")}\` for a local module dir, \`${t("terraform_module_lookup")}\` for a registry-sourced one), and emit a \`moved {}\` block for EVERY relocated address (\`${t("terraform_generate_moved")}\`).
|
|
25
25
|
- \`terraform fmt\` + \`${t("terraform_validate")}\`, then — **with the refactor edits still UNCOMMITTED** — call \`${t("terraform_equivalence_check")}\`. In this mode it diffs the working tree against the COMMITTED fix (not the run-start commit), so it measures only the refactor. It must return \`equivalent: true\` (zero uncovered moves, resource set + arguments preserved, validate + fmt clean) before you proceed; \`${t("push_branch")}\` hard-blocks an unproven one — and, once this run has added a \`moved {}\` block, it also hard-blocks a push that never ran the check at all, so abandoning the refactor means REVERTING its edits rather than leaving them in with the fix. If it can't be proven equivalent, abandon the refactor and ship the fix alone.
|
|
26
26
|
- **document the module (only when the \`docs\` input is enabled)**: after the equivalence check passes, call \`${t("terraform_module_docs")}\` for the module you created/adopted (pass the relocated addresses as \`moves\`). Do not hand-write docs — the tool builds from the interface, moved blocks, and equivalence verdict.
|
package/src/modes/remediate.ts
CHANGED
|
@@ -34,8 +34,8 @@ export function remediateMode(t: ToolRef): Mode {
|
|
|
34
34
|
|
|
35
35
|
4. **for the chosen group**:
|
|
36
36
|
- **base branch**: this run's base branch is resolved deterministically — \`${t("create_pull_request")}\` targets the \`base_branch\` input if set, else the branch the run started on, 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.
|
|
37
|
-
- **idempotency**: the remediation branch is \`remediate/<group-id
|
|
38
|
-
- **branch**: create
|
|
37
|
+
- **idempotency**: the remediation branch is the group's \`branch\` (\`remediate/<group-id>\`, with a \`--<base>\` suffix when the PR targets a non-default branch) — use it exactly, never a name of your own. 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.
|
|
38
|
+
- **branch**: create the group's \`branch\` from the **current HEAD** (the checkout that was just scanned) via \`${t("git")}\` (\`git({ command: "checkout", args: ["-b", <branch>] })\`). Do NOT switch to a different base first — branching from the scanned checkout keeps the PR diff to exactly your fix.
|
|
39
39
|
- **honest refusal (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.
|
|
40
40
|
- **propose, then let me steer (when there's no single right fix)**: distinct from honest refusal (which refuses a fix a human must *decide*), this 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 \`${COMMENT_COMMAND} 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.
|
|
41
41
|
- **reuse a proven fix (optional, do this BEFORE editing)**: call \`${t("terraform_fix_memory")}\`. When proven-fix memory is enabled it returns any patterns whose \`finding_type\` matches this group's concerns — each a transformation that already passed a \`✗→✓\` proof on this repo or elsewhere in the fleet, with its \`remediation_hint\` + a \`before_example\`. Use a matching pattern as your STARTING POINT (it saves you rediscovering the approach), but ADAPT it to this repo's own resources — it is a prior, never a patch to paste. You MUST still run \`${t("terraform_verify_remediation")}\` to prove \`✗→✓\` here; a pattern is never blind-applied and never substitutes for the per-target proof. Returns an empty list (never an error) when memory is off or nothing matches — then fix from first principles as usual.
|
|
@@ -137,6 +137,7 @@ export const Inputs = type({
|
|
|
137
137
|
// org-repo | author | third-party). Structured scope, not prompt text — see
|
|
138
138
|
// parseRefactorSource / withRefactorSource. Gates the behaviour-altering b.4.
|
|
139
139
|
"refactor_source?": type.string.or("undefined"),
|
|
140
|
+
"refactor_examples?": type.string.or("undefined"),
|
|
140
141
|
"review_instructions?": type.string.or("undefined"),
|
|
141
142
|
"fp_filtering_instructions?": type.string.or("undefined"),
|
|
142
143
|
"remediation_instructions?": type.string.or("undefined"),
|
|
@@ -237,6 +238,7 @@ function resolveNonPromptInputs() {
|
|
|
237
238
|
registry_token: core.getInput("registry_token") || undefined,
|
|
238
239
|
registry_host: core.getInput("registry_host") || undefined,
|
|
239
240
|
refactor_source: core.getInput("refactor_source") || undefined,
|
|
241
|
+
refactor_examples: core.getInput("refactor_examples") || undefined,
|
|
240
242
|
review_instructions: core.getInput("review_instructions") || undefined,
|
|
241
243
|
fp_filtering_instructions: core.getInput("fp_filtering_instructions") || undefined,
|
|
242
244
|
remediation_instructions: core.getInput("remediation_instructions") || undefined,
|
|
@@ -690,6 +692,8 @@ export function resolvePayload(
|
|
|
690
692
|
// Refactor mode for the b.4 consent gate. undefined → auto. Action-input/
|
|
691
693
|
// trusted-dispatch only (not the untrusted repo file).
|
|
692
694
|
refactorSource,
|
|
695
|
+
// opt in to refactoring `examples/` roots, which Refactor leaves alone by default
|
|
696
|
+
refactorExamples: parseBooleanInput(inputs.refactor_examples),
|
|
693
697
|
// a `@infraweaver fix …` command parsed from the triggering comment
|
|
694
698
|
// body (the raw prompt), scoping the run to a specific concern/severity/file.
|
|
695
699
|
// null when the prompt isn't a recognised command.
|