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
|
@@ -9,30 +9,30 @@ export const AZURE_PRICE_REGION = "westeurope";
|
|
|
9
9
|
/** Currency of every price below. */
|
|
10
10
|
export const AZURE_PRICE_CURRENCY = "EUR";
|
|
11
11
|
/** ISO date the snapshot was captured — surfaced in cost.md so staleness is visible. */
|
|
12
|
-
export const AZURE_PRICE_FETCHED_AT = "2026-
|
|
12
|
+
export const AZURE_PRICE_FETCHED_AT = "2026-10-03";
|
|
13
13
|
|
|
14
14
|
/** meter key → list price per HOUR. */
|
|
15
15
|
export const AZURE_HOURLY_PRICES: Record<string, number> = {
|
|
16
|
-
"app-service-p0v3": 0.
|
|
17
|
-
"app-service-p1v3": 0.
|
|
18
|
-
"app-service-p2v3": 0.
|
|
19
|
-
"app-service-p3v3": 0.
|
|
20
|
-
"bastion-basic": 0.
|
|
21
|
-
"bastion-premium": 0.
|
|
22
|
-
"bastion-standard": 0.
|
|
23
|
-
"firewall-basic": 0.
|
|
24
|
-
"firewall-premium": 1.
|
|
25
|
-
"firewall-standard": 1.
|
|
26
|
-
"redis-c0": 0.
|
|
27
|
-
"redis-c1": 0.
|
|
28
|
-
"redis-c2": 0.
|
|
29
|
-
"redis-c3": 0.
|
|
30
|
-
"redis-p1": 0.
|
|
31
|
-
"redis-p2": 0.
|
|
32
|
-
"redis-p3": 0.
|
|
33
|
-
"vpn-gateway-vpngw1": 0.
|
|
34
|
-
"vpn-gateway-vpngw2": 0.
|
|
35
|
-
"vpn-gateway-vpngw3": 1.
|
|
36
|
-
"vpn-gateway-vpngw4": 1.
|
|
37
|
-
"vpn-gateway-vpngw5": 3.
|
|
16
|
+
"app-service-p0v3": 0.0783,
|
|
17
|
+
"app-service-p1v3": 0.1566,
|
|
18
|
+
"app-service-p2v3": 0.3133,
|
|
19
|
+
"app-service-p3v3": 0.6265,
|
|
20
|
+
"bastion-basic": 0.1672,
|
|
21
|
+
"bastion-premium": 0.396,
|
|
22
|
+
"bastion-standard": 0.2552,
|
|
23
|
+
"firewall-basic": 0.3476,
|
|
24
|
+
"firewall-premium": 1.54,
|
|
25
|
+
"firewall-standard": 1.1,
|
|
26
|
+
"redis-c0": 0.0242,
|
|
27
|
+
"redis-c1": 0.0607,
|
|
28
|
+
"redis-c2": 0.0986,
|
|
29
|
+
"redis-c3": 0.198,
|
|
30
|
+
"redis-p1": 0.2438,
|
|
31
|
+
"redis-p2": 0.4884,
|
|
32
|
+
"redis-p3": 0.9759,
|
|
33
|
+
"vpn-gateway-vpngw1": 0.1672,
|
|
34
|
+
"vpn-gateway-vpngw2": 0.4312,
|
|
35
|
+
"vpn-gateway-vpngw3": 1.1,
|
|
36
|
+
"vpn-gateway-vpngw4": 1.8479,
|
|
37
|
+
"vpn-gateway-vpngw5": 3.2119,
|
|
38
38
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
2
|
+
import { remediationBranchSuffix } from "#app/mcp/staleFix";
|
|
2
3
|
import {
|
|
3
4
|
capSeededConcerns,
|
|
4
5
|
type PrioritySignals,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
type Severity,
|
|
25
26
|
sortConcerns,
|
|
26
27
|
} from "#app/mcp/terraform/types";
|
|
28
|
+
import { recordConcernSites } from "#app/mcp/terraform/verifySites";
|
|
27
29
|
|
|
28
30
|
export interface ConcernResult {
|
|
29
31
|
/** non-suppressed concerns ≥ threshold, sorted */
|
|
@@ -65,6 +67,18 @@ export function annotateConcerns(
|
|
|
65
67
|
);
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/** The branch this run's PRs target, as `create_pull_request` resolves it: the
|
|
71
|
+
* `base_branch` input, else the branch the run started on, else the default. */
|
|
72
|
+
function remediationBaseOf(ctx: LocalToolContext): string | undefined {
|
|
73
|
+
const head = ctx.toolState.initialHead;
|
|
74
|
+
return (
|
|
75
|
+
ctx.payload.baseBranch?.trim() ||
|
|
76
|
+
(head?.kind === "branch" ? head.name : undefined) ||
|
|
77
|
+
ctx.repo?.data.default_branch?.trim() ||
|
|
78
|
+
undefined
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
/**
|
|
69
83
|
* The shared concerns→result pipeline behind `terraform_scan` and `read_findings`:
|
|
70
84
|
* capture the line-pinned + line-independent baseline (so verify's regression diff
|
|
@@ -115,6 +129,7 @@ export function buildConcernResult(
|
|
|
115
129
|
fullBaseline.map((c) => concernKeyOf(c)),
|
|
116
130
|
);
|
|
117
131
|
recordConcernKeys(session, fullBaseline);
|
|
132
|
+
recordConcernSites(session, fullBaseline, opts.cwd);
|
|
118
133
|
// the files already unformatted at this (pre-fix) scan. terraform_validate
|
|
119
134
|
// subtracts these so PRE-EXISTING `terraform fmt` drift can't force a
|
|
120
135
|
// whole-file reformat into a scoped fix. Captured from the raw set (fmt
|
|
@@ -141,10 +156,17 @@ export function buildConcernResult(
|
|
|
141
156
|
active,
|
|
142
157
|
opts.autonomyThreshold,
|
|
143
158
|
);
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
const suffix = remediationBranchSuffix(
|
|
160
|
+
remediationBaseOf(ctx),
|
|
161
|
+
ctx.repo?.data.default_branch?.trim() || undefined,
|
|
162
|
+
);
|
|
163
|
+
const groups = (
|
|
164
|
+
opts.priority ? prioritizeGroups(annotatedGroups, active, opts.priority) : annotatedGroups
|
|
165
|
+
).map((g) => ({ ...g, branch: `remediate/${g.id}${suffix}` }));
|
|
166
|
+
const planned = planBatches(groups);
|
|
167
|
+
const batchPlan = planned.batch_branch
|
|
168
|
+
? { ...planned, batch_branch: `${planned.batch_branch}${suffix}` }
|
|
169
|
+
: planned;
|
|
148
170
|
|
|
149
171
|
const bySeverity: Record<string, number> = {};
|
|
150
172
|
for (const c of active) bySeverity[c.severity] = (bySeverity[c.severity] ?? 0) + 1;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential written into the configuration as a string literal, by the
|
|
3
|
+
* name of the argument that holds it.
|
|
4
|
+
*
|
|
5
|
+
* WHY BY NAME. The secret scanners look at the VALUE — a known key format, or a
|
|
6
|
+
* string with enough entropy — so a hand-made password (`"cg-flagshop-Pr0d!…"`)
|
|
7
|
+
* slips past all of them. Terraform says more than a text scanner can see: the
|
|
8
|
+
* value sits in an argument, a map key or a variable whose name says what it
|
|
9
|
+
* holds. A literal in `password`, `client_secret` or a Lambda environment
|
|
10
|
+
* variable called `auth` is in the repository whatever it looks like.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS READ. Every `name = "…"` line inside a `resource`, `data`,
|
|
13
|
+
* `provider`, `module` or `locals` block, at any depth, so
|
|
14
|
+
* `environment { variables = { auth = "…" } }` and `jsonencode({ … })` objects
|
|
15
|
+
* are reached; and the `default` of a `variable` whose name is a secret's. Not
|
|
16
|
+
* read: comments, heredoc bodies, a name/value pair split across two keys
|
|
17
|
+
* (`[{ name = "PASSWORD", value = "…" }]`), and a value that starts on a later
|
|
18
|
+
* line than its name.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IS NOT A FINDING. A reference, a function call, an interpolation, an
|
|
21
|
+
* empty string, or a placeholder made only of `*` / `x` / `<…>`. Neither is an
|
|
22
|
+
* argument whose name only mentions a secret (`password_length`, `secret_name`,
|
|
23
|
+
* `token_validity_units`, `creation_token`): the last word of the name decides.
|
|
24
|
+
*
|
|
25
|
+
* Arguments the provider can take write-only (`aws_db_instance.password`) may
|
|
26
|
+
* also be reported by `secret-persisted-to-state`, which says something else:
|
|
27
|
+
* that the value reaches state. This one says it is in git.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { isStringLiteral, stripTrailingComment } from "#app/mcp/terraform/hcl";
|
|
31
|
+
import { HARDCODED_SECRET_ARGUMENT, NATIVE_SOURCE } from "#app/mcp/terraform/nativeRules";
|
|
32
|
+
import type { SourceFile } from "#app/mcp/terraform/tree";
|
|
33
|
+
import { buildConcern, type Concern } from "#app/mcp/terraform/types";
|
|
34
|
+
|
|
35
|
+
/** a name's last word, alone, says it holds a secret. */
|
|
36
|
+
const SECRET_WORDS = new Set([
|
|
37
|
+
"password",
|
|
38
|
+
"passwd",
|
|
39
|
+
"pwd",
|
|
40
|
+
"passphrase",
|
|
41
|
+
"secret",
|
|
42
|
+
"token",
|
|
43
|
+
"auth",
|
|
44
|
+
"apikey",
|
|
45
|
+
"credential",
|
|
46
|
+
"credentials",
|
|
47
|
+
]);
|
|
48
|
+
/** `<word>_key` names that hold a secret (`api_key`, `secret_key`, …). */
|
|
49
|
+
const SECRET_KEY_PREFIXES = new Set(["api", "secret", "private", "signing"]);
|
|
50
|
+
/** `<word>_token` names that are identifiers, not credentials. */
|
|
51
|
+
const NOT_SECRET_TOKENS = new Set(["creation", "idempotency", "client", "request", "continuation"]);
|
|
52
|
+
|
|
53
|
+
function words(name: string): string[] {
|
|
54
|
+
return name
|
|
55
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.split(/[_-]+/)
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when an argument, map key or variable called `name` holds a secret. */
|
|
62
|
+
export function isSecretName(name: string): boolean {
|
|
63
|
+
const w = words(name);
|
|
64
|
+
const last = w.at(-1);
|
|
65
|
+
const prev = w.at(-2);
|
|
66
|
+
if (last === undefined) return false;
|
|
67
|
+
if (last === "key") return prev !== undefined && SECRET_KEY_PREFIXES.has(prev);
|
|
68
|
+
if (last === "token" && prev !== undefined && NOT_SECRET_TOKENS.has(prev)) return false;
|
|
69
|
+
return SECRET_WORDS.has(last);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A literal that says it is not a real value: empty, masked, or `<…>`. */
|
|
73
|
+
function isPlaceholder(inner: string): boolean {
|
|
74
|
+
return (
|
|
75
|
+
inner.trim() === "" ||
|
|
76
|
+
/^[*xX.]+$/.test(inner) ||
|
|
77
|
+
/^<[^>]*>$/.test(inner) ||
|
|
78
|
+
// the dummy credentials offline plans and LocalStack are given (`secret_key = "test"`)
|
|
79
|
+
/^(?:test|mock|fake|dummy|fixture|example|placeholder|sample)(?:[_-].*)?$/i.test(inner) ||
|
|
80
|
+
// a token's lifetime unit: `token_validity_units { access_token = "hours" }`
|
|
81
|
+
/^(?:seconds|minutes|hours|days)$/.test(inner)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface HardcodedSecret {
|
|
86
|
+
/** the argument, map key or variable name. */
|
|
87
|
+
name: string;
|
|
88
|
+
/** what holds it: `resource "aws_lambda_function" "x"`, `variable "db_password"`, … */
|
|
89
|
+
holder: string;
|
|
90
|
+
line: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** a top-level block header: `resource "aws_db_instance" "db" {`, `locals {` */
|
|
94
|
+
const HEADER = /^(resource|data|provider|module|locals|variable|output|terraform)\b([^{]*)\{\s*$/;
|
|
95
|
+
/** `name = value`, the name bare or quoted */
|
|
96
|
+
const ASSIGNMENT = /^\s*(?:"([A-Za-z_][\w-]*)"|([A-Za-z_][\w-]*))\s*=(?!=)\s*(.*)$/;
|
|
97
|
+
const HEREDOC = /<<-?([A-Za-z_]\w*)\s*$/;
|
|
98
|
+
/** a `key = "string"` pair inside a one-line object */
|
|
99
|
+
const INLINE_PAIR = /(?:"([A-Za-z_][\w-]*)"|\b([A-Za-z_][\w-]*))\s*=(?!=)\s*("(?:[^"\\]|\\.)*")/g;
|
|
100
|
+
|
|
101
|
+
function literalValue(raw: string): boolean {
|
|
102
|
+
const value = stripTrailingComment(raw).replace(/,$/, "").trim();
|
|
103
|
+
return isStringLiteral(value) && !isPlaceholder(value.slice(1, -1));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every secret-named literal in one file. Pure.
|
|
108
|
+
*
|
|
109
|
+
* One pass over the lines rather than a block parse: every assignment line is
|
|
110
|
+
* the same question whatever block it sits in, and the native pack's block
|
|
111
|
+
* parser is shared by every scanner and budgeted (see nativeScan.perf.test).
|
|
112
|
+
*/
|
|
113
|
+
export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
|
|
114
|
+
const out: HardcodedSecret[] = [];
|
|
115
|
+
let holder: string | null = null;
|
|
116
|
+
let heredoc: string | null = null;
|
|
117
|
+
let inComment = false;
|
|
118
|
+
const lines = hcl.split("\n");
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const line = (lines[i] as string).replace(/\r$/, "");
|
|
121
|
+
if (heredoc !== null) {
|
|
122
|
+
if (line.trim() === heredoc) heredoc = null;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (inComment) {
|
|
126
|
+
if (line.includes("*/")) inComment = false;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const trimmed = line.trimStart();
|
|
130
|
+
if (trimmed.startsWith("/*")) {
|
|
131
|
+
inComment = !trimmed.includes("*/");
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
|
|
135
|
+
const header = HEADER.exec(line);
|
|
136
|
+
if (header) {
|
|
137
|
+
holder = `${header[1]}${(header[2] as string).trimEnd() ? ` ${(header[2] as string).trim()}` : ""}`;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (line.startsWith("}")) {
|
|
141
|
+
holder = null;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const opens = HEREDOC.exec(line);
|
|
145
|
+
if (opens) heredoc = opens[1] as string;
|
|
146
|
+
if (holder === null || opens) continue;
|
|
147
|
+
const m = ASSIGNMENT.exec(line);
|
|
148
|
+
if (!m) continue;
|
|
149
|
+
const name = (m[1] ?? m[2]) as string;
|
|
150
|
+
const value = m[3] as string;
|
|
151
|
+
if (holder.startsWith("variable ")) {
|
|
152
|
+
// a variable's literal is its default, and the variable's name says what it holds
|
|
153
|
+
const variable = /^variable\s+"?([\w-]+)"?/.exec(holder)?.[1] ?? "";
|
|
154
|
+
if (name === "default" && isSecretName(variable) && literalValue(value)) {
|
|
155
|
+
out.push({ name: variable, holder, line: i + 1 });
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (holder.startsWith("output ") || holder.startsWith("terraform")) continue;
|
|
160
|
+
if (isSecretName(name) && literalValue(value)) {
|
|
161
|
+
out.push({ name, holder, line: i + 1 });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// an object written on one line holds its own pairs: `{ "DB_PASSWORD" = "…" }`
|
|
165
|
+
for (const pair of value.matchAll(INLINE_PAIR)) {
|
|
166
|
+
const key = (pair[1] ?? pair[2]) as string;
|
|
167
|
+
if (isSecretName(key) && literalValue(pair[3] as string)) {
|
|
168
|
+
out.push({ name: key, holder, line: i + 1 });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Scan an already-read tree into native concerns. Pure; no I/O of its own. */
|
|
176
|
+
export function scanHardcodedSecrets(files: readonly SourceFile[]): Concern[] {
|
|
177
|
+
const concerns: Concern[] = [];
|
|
178
|
+
for (const { path, hcl } of files) {
|
|
179
|
+
for (const hit of findHardcodedSecrets(hcl)) {
|
|
180
|
+
concerns.push(
|
|
181
|
+
buildConcern({
|
|
182
|
+
source: NATIVE_SOURCE,
|
|
183
|
+
rule: HARDCODED_SECRET_ARGUMENT,
|
|
184
|
+
file: path,
|
|
185
|
+
line: hit.line,
|
|
186
|
+
// a credential in the repository is readable by everyone who can read
|
|
187
|
+
// it, and stays in the history after it is edited out
|
|
188
|
+
severity: "high",
|
|
189
|
+
category: "security",
|
|
190
|
+
evidence:
|
|
191
|
+
`\`${hit.name}\` in \`${hit.holder}\` is a string literal, so the credential it holds is ` +
|
|
192
|
+
"committed to the repository (value not quoted here)",
|
|
193
|
+
remediation_hint:
|
|
194
|
+
"Rotate the credential first — it stays in the branch history after the line is changed. " +
|
|
195
|
+
"Then pass it in at run time: a `sensitive` variable with no default, or a read from a " +
|
|
196
|
+
"secret store (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager).",
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return concerns;
|
|
202
|
+
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { scanDialect } from "#app/mcp/terraform/dialect";
|
|
28
28
|
import { scanGovernanceFile } from "#app/mcp/terraform/governance";
|
|
29
|
+
import { scanHardcodedSecrets } from "#app/mcp/terraform/hardcodedSecrets";
|
|
29
30
|
import { scanInterfaceHygiene } from "#app/mcp/terraform/moduleInterfaceRules";
|
|
30
31
|
import { scanUnpinnedModules } from "#app/mcp/terraform/moduleVersionConstraints";
|
|
31
32
|
import {
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
DEPRECATED_S3_BUCKET_ARGUMENT,
|
|
35
36
|
DEPRECATED_STATE_LOCKING,
|
|
36
37
|
DYNAMIC_DESTROY_PROTECTION,
|
|
38
|
+
HARDCODED_SECRET_ARGUMENT,
|
|
37
39
|
IMPERATIVE_ACTION_TRIGGER,
|
|
38
40
|
LEGACY_BLOCK_ASSIGNMENT,
|
|
39
41
|
LEGACY_INTERPOLATION,
|
|
@@ -125,6 +127,12 @@ export const NATIVE_RULE_SCANNERS: readonly NativeRuleScanner[] = [
|
|
|
125
127
|
// one. See [[stateFiles]].
|
|
126
128
|
scan: (ctx) => scanCommittedStateFiles(ctx.cwd),
|
|
127
129
|
},
|
|
130
|
+
{
|
|
131
|
+
name: "hardcoded-secrets",
|
|
132
|
+
rules: [HARDCODED_SECRET_ARGUMENT],
|
|
133
|
+
// Per file: the argument's name and its literal are both on the line itself.
|
|
134
|
+
scan: (ctx) => ({ concerns: scanHardcodedSecrets(ctx.files) }),
|
|
135
|
+
},
|
|
128
136
|
{
|
|
129
137
|
name: "write-only",
|
|
130
138
|
rules: [SECRET_PERSISTED_TO_STATE],
|
|
@@ -59,6 +59,7 @@ export interface NativeRule {
|
|
|
59
59
|
|
|
60
60
|
export const SECRET_PERSISTED_TO_STATE = "secret-persisted-to-state";
|
|
61
61
|
export const COMMITTED_STATE_FILE = "committed-state-file";
|
|
62
|
+
export const HARDCODED_SECRET_ARGUMENT = "hardcoded-secret-argument";
|
|
62
63
|
export const IMPERATIVE_ACTION_TRIGGER = "imperative-action-trigger";
|
|
63
64
|
export const DYNAMIC_DESTROY_PROTECTION = "dynamic-destroy-protection";
|
|
64
65
|
export const DEPRECATED_STATE_LOCKING = "deprecated-state-locking";
|
|
@@ -103,6 +104,19 @@ export const NATIVE_RULES: readonly NativeRule[] = [
|
|
|
103
104
|
"that the state contains a live credential, or that anyone has read it — the file is never opened; " +
|
|
104
105
|
"the finding is that state is under version control, and what it recorded is for the owner to say",
|
|
105
106
|
},
|
|
107
|
+
{
|
|
108
|
+
id: HARDCODED_SECRET_ARGUMENT,
|
|
109
|
+
title: "Credential written into the configuration as a literal",
|
|
110
|
+
band: "verified",
|
|
111
|
+
category: "security",
|
|
112
|
+
asserts:
|
|
113
|
+
"an argument, map key or variable whose name says it holds a credential (`password`, `token`, " +
|
|
114
|
+
"`client_secret`, `api_key`, …) is assigned a string literal, so the value is in the repository " +
|
|
115
|
+
"and in its history",
|
|
116
|
+
doesNotAssert:
|
|
117
|
+
"that the value is a live credential — it may be a test value; the finding is that a credential " +
|
|
118
|
+
"slot is filled from the code rather than at run time",
|
|
119
|
+
},
|
|
106
120
|
{
|
|
107
121
|
id: IMPERATIVE_ACTION_TRIGGER,
|
|
108
122
|
title: "Imperative side-effect invoked from declarative IaC (action / action_trigger)",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Refactor run may propose, beyond what the detectors find.
|
|
3
|
+
*
|
|
4
|
+
* EXAMPLES ARE NOT REFACTORED BY DEFAULT. An `examples/` root is documentation
|
|
5
|
+
* that happens to plan: its resources exist to show a caller how to use the
|
|
6
|
+
* module, so they repeat on purpose, and a PR that consolidates or extracts them
|
|
7
|
+
* rewrites the docs to save nobody anything. The `refactor_examples` input opts
|
|
8
|
+
* back in.
|
|
9
|
+
*
|
|
10
|
+
* A REUSABLE MODULE IS REPORTED, NOT RESTRUCTURED. A repository whose root
|
|
11
|
+
* configures no provider and no backend, and ships `examples/`, is a module other
|
|
12
|
+
* people call (terraform-aws-vpc, say). Moving its resources into a submodule is
|
|
13
|
+
* provably equivalent, and still churn for every consumer: their plans gain a
|
|
14
|
+
* wall of `moved` addresses for no change in what they get. There, extraction
|
|
15
|
+
* and normalisation candidates come back marked `report_only` — named in the
|
|
16
|
+
* run's summary, never opened as a PR. Consolidating roots that duplicate each
|
|
17
|
+
* other is unaffected: that removes real duplication, not style.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
/** true for a path inside an `examples/` (or `example/`) directory, at any depth. */
|
|
23
|
+
export function isExamplePath(path: string): boolean {
|
|
24
|
+
return /(?:^|\/)examples?\//.test(path.replace(/\\/g, "/"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PROVIDER_OR_BACKEND = /^\s*(?:provider\s+"|backend\s+")/m;
|
|
28
|
+
|
|
29
|
+
/** whether some directory under `dir` (to a small depth) holds a `.tf` file. */
|
|
30
|
+
function holdsTerraform(dir: string, depth: number): boolean {
|
|
31
|
+
let entries: string[];
|
|
32
|
+
try {
|
|
33
|
+
entries = readdirSync(dir);
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
for (const name of entries) {
|
|
38
|
+
const path = join(dir, name);
|
|
39
|
+
if (name.endsWith(".tf")) return true;
|
|
40
|
+
if (
|
|
41
|
+
depth > 0 &&
|
|
42
|
+
!name.startsWith(".") &&
|
|
43
|
+
statSync(path, { throwIfNoEntry: false })?.isDirectory()
|
|
44
|
+
) {
|
|
45
|
+
if (holdsTerraform(path, depth - 1)) return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Why the repository at `cwd` reads as a reusable module rather than a
|
|
53
|
+
* deployment, or null when it does not.
|
|
54
|
+
*/
|
|
55
|
+
export function libraryRepoReason(cwd: string): string | null {
|
|
56
|
+
let rootFiles: string[];
|
|
57
|
+
try {
|
|
58
|
+
rootFiles = readdirSync(cwd).filter((f) => f.endsWith(".tf"));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (rootFiles.length === 0) return null;
|
|
63
|
+
for (const f of rootFiles) {
|
|
64
|
+
let text: string;
|
|
65
|
+
try {
|
|
66
|
+
text = readFileSync(join(cwd, f), "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (PROVIDER_OR_BACKEND.test(text)) return null;
|
|
71
|
+
}
|
|
72
|
+
const examples = ["examples", "example"].find((d) => existsSync(join(cwd, d)));
|
|
73
|
+
if (examples === undefined || !holdsTerraform(join(cwd, examples), 2)) return null;
|
|
74
|
+
return (
|
|
75
|
+
`the repository root configures no provider and no backend, and ships \`${examples}/\`: it is a ` +
|
|
76
|
+
"module other configurations call, so restructuring it is churn for every caller"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** what a detector tool adds to its result when the repository is a library. */
|
|
81
|
+
export function reportOnly(reason: string | null): {
|
|
82
|
+
report_only?: true;
|
|
83
|
+
report_only_reason?: string;
|
|
84
|
+
} {
|
|
85
|
+
return reason === null ? {} : { report_only: true, report_only_reason: reason };
|
|
86
|
+
}
|
|
@@ -77,6 +77,12 @@ export interface ScanSession {
|
|
|
77
77
|
// agent took from the FIRST scan (`concernKeyOf`), or it falls back to
|
|
78
78
|
// matching ids exactly and calls every line-shifted finding resolved.
|
|
79
79
|
concernsById?: Map<string, Concern>;
|
|
80
|
+
// the resource each reported concern sat in when it was reported (by id), and
|
|
81
|
+
// per (scanner, rule, file) key the resources the rule fired on the first time
|
|
82
|
+
// the run saw that key. terraform_verify_remediation reads both to count a fix
|
|
83
|
+
// on one resource while the rule still fires on another (see verifySites).
|
|
84
|
+
siteById?: Map<string, string | null>;
|
|
85
|
+
firstSitesByKey?: Map<string, Set<string | null>>;
|
|
80
86
|
// the findings gate's concern set: scanned by the engine itself before the
|
|
81
87
|
// agent starts, on the tree the run was invoked on, at the CONFIGURED
|
|
82
88
|
// threshold and scope. read at end-of-run by finalizeSuccessRun for the
|
|
@@ -364,6 +364,8 @@ const SUBJECT: Record<string, RegExp> = {
|
|
|
364
364
|
// rule's silence a real statement. Whether git could answer is handled one
|
|
365
365
|
// level up by the blind set: a run that could not ask is never conforming.
|
|
366
366
|
"committed-state-file": ANY_BLOCK,
|
|
367
|
+
// any block may hold a credential argument, a map of them, or a variable default
|
|
368
|
+
"hardcoded-secret-argument": ANY_BLOCK,
|
|
367
369
|
"deprecated-s3-bucket-argument": /(?:^|\n)\s*resource\s+"?aws_s3_bucket(?:"|\s)/,
|
|
368
370
|
"superseded-resource-type":
|
|
369
371
|
/(?:^|\n)\s*(?:resource\s+"?(?:aws_launch_configuration|aws_elb)(?:"|\s)|data\s+"?template_file(?:"|\s))/,
|
|
@@ -3,6 +3,7 @@ import type { LocalToolContext } from "#app/mcp/localContext";
|
|
|
3
3
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
4
4
|
import { findConsolidationGroups } from "#app/mcp/terraform/consolidation";
|
|
5
5
|
import { readWorkingTree } from "#app/mcp/terraform/refactor/workspace";
|
|
6
|
+
import { isExamplePath } from "#app/mcp/terraform/refactorScope";
|
|
6
7
|
import { log } from "#app/utils/cli";
|
|
7
8
|
|
|
8
9
|
const ConsolidationCandidatesParams = type({});
|
|
@@ -37,7 +38,10 @@ export function ConsolidationCandidatesTool(ctx: LocalToolContext) {
|
|
|
37
38
|
parameters: ConsolidationCandidatesParams,
|
|
38
39
|
execute: execute(async () => {
|
|
39
40
|
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
40
|
-
const
|
|
41
|
+
const tree = readWorkingTree(cwd);
|
|
42
|
+
const result = findConsolidationGroups(
|
|
43
|
+
ctx.payload.refactorExamples ? tree : tree.filter((f) => !isExamplePath(f.path)),
|
|
44
|
+
);
|
|
41
45
|
log.info(
|
|
42
46
|
`» terraform_consolidation_candidates: ${result.groups.length} group(s), ` +
|
|
43
47
|
`${result.near_groups.length} near-group(s), ${result.declined_groups.length} declined`,
|
|
@@ -2,6 +2,7 @@ import { type } from "arktype";
|
|
|
2
2
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
3
3
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
4
4
|
import { findNormalizationCandidates } from "#app/mcp/terraform/normalization";
|
|
5
|
+
import { isExamplePath, libraryRepoReason, reportOnly } from "#app/mcp/terraform/refactorScope";
|
|
5
6
|
import { log } from "#app/utils/cli";
|
|
6
7
|
|
|
7
8
|
// --- the tool ----------------------------------------------------------------
|
|
@@ -28,12 +29,15 @@ export function NormalizationCandidatesTool(ctx: LocalToolContext) {
|
|
|
28
29
|
parameters: NormalizationCandidatesParams,
|
|
29
30
|
execute: execute(async () => {
|
|
30
31
|
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
31
|
-
const files = findNormalizationCandidates(cwd)
|
|
32
|
+
const files = findNormalizationCandidates(cwd).filter(
|
|
33
|
+
(f) => ctx.payload.refactorExamples || !isExamplePath(f.file),
|
|
34
|
+
);
|
|
32
35
|
const total = files.reduce((n, f) => n + f.count, 0);
|
|
33
36
|
log.info(
|
|
34
37
|
`» terraform_normalization_candidates: ${total} site(s) across ${files.length} file(s)`,
|
|
35
38
|
);
|
|
36
39
|
return toolOk({
|
|
40
|
+
...reportOnly(libraryRepoReason(cwd)),
|
|
37
41
|
file_count: files.length,
|
|
38
42
|
total,
|
|
39
43
|
files,
|
|
@@ -3,6 +3,7 @@ import type { LocalToolContext } from "#app/mcp/localContext";
|
|
|
3
3
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
4
4
|
import { computeConfidence } from "#app/mcp/terraform/decisions";
|
|
5
5
|
import { captureProvenFix } from "#app/mcp/terraform/fixMemory";
|
|
6
|
+
import { declaredAddresses } from "#app/mcp/terraform/scanDelta";
|
|
6
7
|
import {
|
|
7
8
|
computeRegressions,
|
|
8
9
|
computeRemediationVerdict,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
} from "#app/mcp/terraform/scanners";
|
|
13
14
|
import { hasBaseline, scanSessionOf } from "#app/mcp/terraform/scanSession";
|
|
14
15
|
import { concernKeyOf, dedupe } from "#app/mcp/terraform/types";
|
|
16
|
+
import { fileReader, resolvedAtSite, siteOf } from "#app/mcp/terraform/verifySites";
|
|
15
17
|
import { log } from "#app/utils/cli";
|
|
16
18
|
import { resolveToolSelection } from "#app/utils/prompt/toolSelection";
|
|
17
19
|
import { resolveModuleCredentialEnv } from "#app/utils/setup/registryAuth";
|
|
@@ -82,7 +84,27 @@ export async function runVerifyRemediation(
|
|
|
82
84
|
if (key !== undefined) keyed.push({ id, key });
|
|
83
85
|
else unkeyed.push(id);
|
|
84
86
|
}
|
|
85
|
-
const
|
|
87
|
+
const byKey = partitionByKey(keyed, currentKeys);
|
|
88
|
+
// a rule still firing in the file may have been fixed on THIS finding's
|
|
89
|
+
// resource; count it only under the conditions verifySites spells out
|
|
90
|
+
const read = fileReader(cwd);
|
|
91
|
+
const remainingKeyed = keyed.filter((k) => byKey.remaining.includes(k.id));
|
|
92
|
+
const atSite = new Set(
|
|
93
|
+
resolvedAtSite({
|
|
94
|
+
requested: remainingKeyed.map((k) => ({
|
|
95
|
+
...k,
|
|
96
|
+
site: session.siteById?.get(k.id) ?? null,
|
|
97
|
+
file: reported.get(k.id)?.location.file,
|
|
98
|
+
})),
|
|
99
|
+
current: currentConcerns.map((c) => ({ key: concernKeyOf(c), site: siteOf(c, read) })),
|
|
100
|
+
firstSites: session.firstSitesByKey ?? new Map(),
|
|
101
|
+
declared: (file) => new Set(declaredAddresses(read(file) ?? "")),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const keyedVerdict = {
|
|
105
|
+
resolved: [...byKey.resolved, ...byKey.remaining.filter((id) => atSite.has(id))],
|
|
106
|
+
remaining: byKey.remaining.filter((id) => !atSite.has(id)),
|
|
107
|
+
};
|
|
86
108
|
const fallbackVerdict = computeRemediationVerdict(unkeyed, new Set(currentIds));
|
|
87
109
|
const resolved = [...keyedVerdict.resolved, ...fallbackVerdict.resolved];
|
|
88
110
|
const remaining = [...keyedVerdict.remaining, ...fallbackVerdict.remaining];
|
|
@@ -398,6 +398,9 @@ export interface ConcernGroup {
|
|
|
398
398
|
/** stable id — the remediation branch/PR key (`remediate/<id>`). Derived from
|
|
399
399
|
* the file (by-file grouping) or the rule (by-rule grouping). */
|
|
400
400
|
id: string;
|
|
401
|
+
/** the branch its fix is pushed to: `remediate/<id>`, plus `--<base>` when the
|
|
402
|
+
* PR targets a branch other than the repository default. */
|
|
403
|
+
branch?: string;
|
|
401
404
|
/** the group's primary file (by-file) or a human label like "3 files"
|
|
402
405
|
* (by-rule); `files` carries the full list for by-rule groups. */
|
|
403
406
|
file: string;
|