infraweaver 0.3.11 → 0.3.13
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/opencode.d.ts +6 -0
- package/dist/agents/quotaTokens.d.ts +6 -0
- package/dist/cli.mjs +846 -412
- package/dist/index.js +806 -372
- package/dist/internal.js +4 -4
- package/dist/mcp/git.d.ts +6 -4
- package/dist/mcp/localContext.d.ts +7 -0
- package/dist/mcp/reviewCommentLimit.d.ts +55 -5
- package/dist/mcp/reviewMarkers.d.ts +5 -0
- package/dist/mcp/staleFix.d.ts +36 -1
- package/dist/mcp/terraform/awsPrices.d.ts +1 -1
- package/dist/mcp/terraform/azurePrices.d.ts +1 -1
- package/dist/mcp/terraform/concernResult.d.ts +9 -0
- package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
- package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
- package/dist/mcp/terraform/refactorScope.d.ts +14 -2
- package/dist/mcp/terraform/ruleContext.d.ts +3 -0
- package/dist/mcp/terraform/scanSession.d.ts +2 -0
- package/dist/mcp/terraform/tree.d.ts +5 -0
- package/dist/mcp/terraform/types.d.ts +2 -1
- package/dist/mcp/terraform/verifySites.d.ts +56 -0
- package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
- package/package.json +2 -2
- package/src/agents/opencode.ts +8 -1
- package/src/agents/quotaTokens.ts +7 -1
- package/src/mcp/git.ts +64 -12
- package/src/mcp/localContext.ts +6 -1
- package/src/mcp/moduleExtraction.ts +5 -4
- package/src/mcp/review.ts +52 -3
- package/src/mcp/reviewCommentLimit.ts +169 -26
- package/src/mcp/reviewMarkers.ts +11 -0
- package/src/mcp/reviewProvenance.ts +1 -1
- package/src/mcp/staleFix.ts +73 -2
- package/src/mcp/terraform/awsPrices.ts +1 -1
- package/src/mcp/terraform/azurePrices.ts +23 -23
- package/src/mcp/terraform/concernResult.ts +18 -9
- package/src/mcp/terraform/deltaSummary.ts +4 -0
- package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
- package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
- package/src/mcp/terraform/nativeScan.ts +4 -0
- package/src/mcp/terraform/refactor/equivalence.ts +42 -9
- package/src/mcp/terraform/refactorScope.ts +90 -21
- package/src/mcp/terraform/ruleContext.ts +4 -0
- package/src/mcp/terraform/scanDelta.ts +67 -26
- package/src/mcp/terraform/scanSession.ts +6 -0
- package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
- package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
- package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
- package/src/mcp/terraform/tools/readFindings.ts +2 -0
- package/src/mcp/terraform/tools/scan.ts +3 -0
- package/src/mcp/terraform/tools/verifyRemediation.ts +23 -1
- package/src/mcp/terraform/tree.ts +8 -0
- package/src/mcp/terraform/types.ts +2 -1
- package/src/mcp/terraform/verifySites.ts +124 -0
- package/src/modes/refactor.ts +1 -1
- package/src/modes/remediate-and-refactor.ts +1 -1
- package/src/modes/remediate.ts +1 -1
- package/src/phases/runAgentWithWatchdogs.ts +3 -2
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
2
|
-
import {
|
|
2
|
+
import { groupIdFromBranch, type OpenRemediationPr, remediationBranchFor } from "#app/mcp/staleFix";
|
|
3
3
|
import {
|
|
4
4
|
capSeededConcerns,
|
|
5
5
|
type PrioritySignals,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type Severity,
|
|
26
26
|
sortConcerns,
|
|
27
27
|
} from "#app/mcp/terraform/types";
|
|
28
|
+
import { recordConcernSites } from "#app/mcp/terraform/verifySites";
|
|
28
29
|
|
|
29
30
|
export interface ConcernResult {
|
|
30
31
|
/** non-suppressed concerns ≥ threshold, sorted */
|
|
@@ -112,6 +113,14 @@ export function buildConcernResult(
|
|
|
112
113
|
* the full raw set — so a capped concern the re-scan still sees can never
|
|
113
114
|
* read as a regression. */
|
|
114
115
|
seedCaps?: SeedCaps;
|
|
116
|
+
/** The repository's open remediation PRs (see `listOpenRemediationPrs`), so
|
|
117
|
+
* a group that already has one keeps its branch. */
|
|
118
|
+
openRemediationPrs?: readonly OpenRemediationPr[];
|
|
119
|
+
/** The concerns were scanned off the tree in `cwd` as it is now, so their
|
|
120
|
+
* lines name today's resources (see `recordConcernSites`). Only
|
|
121
|
+
* `terraform_scan` sets it: a findings file or external report may
|
|
122
|
+
* describe another commit. */
|
|
123
|
+
linesMatchTree?: boolean;
|
|
115
124
|
},
|
|
116
125
|
): ConcernResult {
|
|
117
126
|
const fullBaseline = dedupe(rawConcerns);
|
|
@@ -128,6 +137,7 @@ export function buildConcernResult(
|
|
|
128
137
|
fullBaseline.map((c) => concernKeyOf(c)),
|
|
129
138
|
);
|
|
130
139
|
recordConcernKeys(session, fullBaseline);
|
|
140
|
+
recordConcernSites(session, fullBaseline, opts.cwd, opts.linesMatchTree ?? false);
|
|
131
141
|
// the files already unformatted at this (pre-fix) scan. terraform_validate
|
|
132
142
|
// subtracts these so PRE-EXISTING `terraform fmt` drift can't force a
|
|
133
143
|
// whole-file reformat into a scoped fix. Captured from the raw set (fmt
|
|
@@ -154,17 +164,16 @@ export function buildConcernResult(
|
|
|
154
164
|
active,
|
|
155
165
|
opts.autonomyThreshold,
|
|
156
166
|
);
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
const base = remediationBaseOf(ctx);
|
|
168
|
+
const defaultBranch = ctx.repo?.data.default_branch?.trim() || undefined;
|
|
169
|
+
const branchFor = (id: string) =>
|
|
170
|
+
remediationBranchFor(id, base, defaultBranch, opts.openRemediationPrs);
|
|
161
171
|
const groups = (
|
|
162
172
|
opts.priority ? prioritizeGroups(annotatedGroups, active, opts.priority) : annotatedGroups
|
|
163
|
-
).map((g) => ({ ...g, branch:
|
|
173
|
+
).map((g) => ({ ...g, branch: branchFor(g.id) }));
|
|
164
174
|
const planned = planBatches(groups);
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
: planned;
|
|
175
|
+
const batchId = planned.batch_branch ? groupIdFromBranch(planned.batch_branch) : null;
|
|
176
|
+
const batchPlan = batchId ? { ...planned, batch_branch: branchFor(batchId) } : planned;
|
|
168
177
|
|
|
169
178
|
const bySeverity: Record<string, number> = {};
|
|
170
179
|
for (const c of active) bySeverity[c.severity] = (bySeverity[c.severity] ?? 0) + 1;
|
|
@@ -42,6 +42,9 @@ export interface DeltaVerdict {
|
|
|
42
42
|
gating: number;
|
|
43
43
|
/** introduced findings on a required control, any severity */
|
|
44
44
|
required: number;
|
|
45
|
+
/** introduced findings that fail either half, each counted once: a high
|
|
46
|
+
* finding on a required control is in both `gating` and `required`. */
|
|
47
|
+
blocking: number;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
/** The gate's verdict on a delta: the same two halves `findings-gate` applies. */
|
|
@@ -53,6 +56,7 @@ export function deltaVerdict(delta: GateDelta, requiredControls: string[]): Delt
|
|
|
53
56
|
pass: gate.pass && required.length === 0,
|
|
54
57
|
gating: gate.gating.length,
|
|
55
58
|
required: required.length,
|
|
59
|
+
blocking: new Set([...gate.gating, ...required]).size,
|
|
56
60
|
};
|
|
57
61
|
}
|
|
58
62
|
|
|
@@ -12,22 +12,28 @@
|
|
|
12
12
|
* WHAT IS READ. Every `name = "…"` line inside a `resource`, `data`,
|
|
13
13
|
* `provider`, `module` or `locals` block, at any depth, so
|
|
14
14
|
* `environment { variables = { auth = "…" } }` and `jsonencode({ … })` objects
|
|
15
|
-
* are reached;
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* are reached; the `default` of a `variable` whose name is a secret's; and
|
|
16
|
+
* every assignment in a `.tfvars` file, which is where a value kept out of the
|
|
17
|
+
* `.tf` source usually lands instead. Not read: comments, heredoc bodies, a
|
|
18
|
+
* name/value pair split across two keys (`[{ name = "PASSWORD", value = "…" }]`),
|
|
19
|
+
* and a value that starts on a later line than its name.
|
|
19
20
|
*
|
|
20
|
-
* WHAT IS NOT A FINDING. A reference, a function call, an interpolation
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* WHAT IS NOT A FINDING. A reference, a function call, an interpolation or a
|
|
22
|
+
* template directive, an empty string, `true` / `false` / `null`, a number, or
|
|
23
|
+
* a placeholder made only of `*` / `x` / `<…>`. Nor is a literal that only POINTS
|
|
24
|
+
* at a secret — an ARN, a GCP `projects/…/secrets/…` name, a Key Vault secret
|
|
25
|
+
* URI or an SSM parameter path — since that is the fix, not the fault. Neither
|
|
26
|
+
* is an argument whose name only mentions a secret (`password_length`,
|
|
27
|
+
* `secret_name`, `token_validity_units`, `creation_token`): the last word of the
|
|
28
|
+
* name decides, and a name that is `secret` and nothing else names a Secret
|
|
29
|
+
* Manager secret (`secret_key_ref { secret = "db-password" }`), not its value.
|
|
24
30
|
*
|
|
25
31
|
* Arguments the provider can take write-only (`aws_db_instance.password`) may
|
|
26
32
|
* also be reported by `secret-persisted-to-state`, which says something else:
|
|
27
33
|
* that the value reaches state. This one says it is in git.
|
|
28
34
|
*/
|
|
29
35
|
|
|
30
|
-
import { isStringLiteral, stripTrailingComment } from "#app/mcp/terraform/hcl";
|
|
36
|
+
import { isStringLiteral, skipNonCode, stripTrailingComment } from "#app/mcp/terraform/hcl";
|
|
31
37
|
import { HARDCODED_SECRET_ARGUMENT, NATIVE_SOURCE } from "#app/mcp/terraform/nativeRules";
|
|
32
38
|
import type { SourceFile } from "#app/mcp/terraform/tree";
|
|
33
39
|
import { buildConcern, type Concern } from "#app/mcp/terraform/types";
|
|
@@ -46,7 +52,18 @@ const SECRET_WORDS = new Set([
|
|
|
46
52
|
"credentials",
|
|
47
53
|
]);
|
|
48
54
|
/** `<word>_key` names that hold a secret (`api_key`, `secret_key`, …). */
|
|
49
|
-
const SECRET_KEY_PREFIXES = new Set([
|
|
55
|
+
const SECRET_KEY_PREFIXES = new Set([
|
|
56
|
+
"api",
|
|
57
|
+
"secret",
|
|
58
|
+
"private",
|
|
59
|
+
"signing",
|
|
60
|
+
"access",
|
|
61
|
+
"account",
|
|
62
|
+
"client",
|
|
63
|
+
"shared",
|
|
64
|
+
"preshared",
|
|
65
|
+
"master",
|
|
66
|
+
]);
|
|
50
67
|
/** `<word>_token` names that are identifiers, not credentials. */
|
|
51
68
|
const NOT_SECRET_TOKENS = new Set(["creation", "idempotency", "client", "request", "continuation"]);
|
|
52
69
|
|
|
@@ -66,6 +83,9 @@ export function isSecretName(name: string): boolean {
|
|
|
66
83
|
if (last === undefined) return false;
|
|
67
84
|
if (last === "key") return prev !== undefined && SECRET_KEY_PREFIXES.has(prev);
|
|
68
85
|
if (last === "token" && prev !== undefined && NOT_SECRET_TOKENS.has(prev)) return false;
|
|
86
|
+
if (last === "string") return prev === "connection";
|
|
87
|
+
// `secret` alone is the NAME of a Secret Manager secret (`secret_key_ref { secret = … }`)
|
|
88
|
+
if (last === "secret" && w.length === 1) return false;
|
|
69
89
|
return SECRET_WORDS.has(last);
|
|
70
90
|
}
|
|
71
91
|
|
|
@@ -75,13 +95,38 @@ function isPlaceholder(inner: string): boolean {
|
|
|
75
95
|
inner.trim() === "" ||
|
|
76
96
|
/^[*xX.]+$/.test(inner) ||
|
|
77
97
|
/^<[^>]*>$/.test(inner) ||
|
|
78
|
-
|
|
79
|
-
|
|
98
|
+
/^(?:true|false|null)$/.test(inner) ||
|
|
99
|
+
/^-?\d+(?:\.\d+)?$/.test(inner) ||
|
|
100
|
+
// a template directive is computed, like an interpolation: `"%{ if … }…%{ endif }"`
|
|
101
|
+
inner.includes("%{") ||
|
|
102
|
+
// the dummy credentials offline plans and LocalStack are given (`secret_key = "test"`,
|
|
103
|
+
// `"example-secret"`) — but only when that is the whole value: a lowercase tail at
|
|
104
|
+
// most, so `"Test-Pr0dDbPassw0rd!"` is a password that happens to start with a word
|
|
105
|
+
isDummy(inner) ||
|
|
80
106
|
// a token's lifetime unit: `token_validity_units { access_token = "hours" }`
|
|
81
107
|
/^(?:seconds|minutes|hours|days)$/.test(inner)
|
|
82
108
|
);
|
|
83
109
|
}
|
|
84
110
|
|
|
111
|
+
function isDummy(inner: string): boolean {
|
|
112
|
+
const m = /^(?:test|mock|fake|dummy|fixture|example|placeholder|sample)([_-].*)?$/i.exec(inner);
|
|
113
|
+
return m !== null && (m[1] === undefined || /^[_-][a-z0-9_-]*$/.test(m[1]));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A literal that names where a secret is kept rather than holding it: a Secrets
|
|
118
|
+
* Manager / SSM ARN, a GCP Secret Manager resource name, a Key Vault secret URI,
|
|
119
|
+
* or an SSM parameter path. Passing one of these is the remedy this rule asks for.
|
|
120
|
+
*/
|
|
121
|
+
function isSecretReference(inner: string): boolean {
|
|
122
|
+
return (
|
|
123
|
+
inner.startsWith("arn:") ||
|
|
124
|
+
/^projects\/[^/]+\/secrets\//.test(inner) ||
|
|
125
|
+
/^https:\/\/[\w-]+\.vault\.azure\.net\/secrets\//i.test(inner) ||
|
|
126
|
+
/^\/[\w./-]+$/.test(inner)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
85
130
|
export interface HardcodedSecret {
|
|
86
131
|
/** the argument, map key or variable name. */
|
|
87
132
|
name: string;
|
|
@@ -90,17 +135,52 @@ export interface HardcodedSecret {
|
|
|
90
135
|
line: number;
|
|
91
136
|
}
|
|
92
137
|
|
|
93
|
-
/** a top-level block header
|
|
94
|
-
const HEADER = /^(resource|data|provider|module|locals|variable|output|terraform)\b([^{]*)\{
|
|
138
|
+
/** a top-level block header, `resource "aws_db_instance" "db" {`, and whatever follows its `{` */
|
|
139
|
+
const HEADER = /^(resource|data|provider|module|locals|variable|output|terraform)\b([^{]*)\{(.*)$/;
|
|
95
140
|
/** `name = value`, the name bare or quoted */
|
|
96
141
|
const ASSIGNMENT = /^\s*(?:"([A-Za-z_][\w-]*)"|([A-Za-z_][\w-]*))\s*=(?!=)\s*(.*)$/;
|
|
97
142
|
const HEREDOC = /<<-?([A-Za-z_]\w*)\s*$/;
|
|
98
143
|
/** a `key = "string"` pair inside a one-line object */
|
|
99
144
|
const INLINE_PAIR = /(?:"([A-Za-z_][\w-]*)"|\b([A-Za-z_][\w-]*))\s*=(?!=)\s*("(?:[^"\\]|\\.)*")/g;
|
|
100
145
|
|
|
101
|
-
function literalValue(raw: string): boolean {
|
|
146
|
+
function literalValue(name: string, raw: string): boolean {
|
|
102
147
|
const value = stripTrailingComment(raw).replace(/,$/, "").trim();
|
|
103
|
-
|
|
148
|
+
if (!isStringLiteral(value)) return false;
|
|
149
|
+
const inner = value.slice(1, -1);
|
|
150
|
+
if (isPlaceholder(inner) || isSecretReference(inner)) return false;
|
|
151
|
+
// an `auth` setting's mode, not its credential: `iam_auth = "DISABLED"`, `auth = "NONE"`
|
|
152
|
+
return !(words(name).at(-1) === "auth" && /^[A-Z]+(?:_[A-Z]+)*$/.test(inner));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** the name a `variable "…"` holder declares. */
|
|
156
|
+
function variableName(holder: string): string {
|
|
157
|
+
return /^variable\s+"?([\w-]+)"?/.exec(holder)?.[1] ?? "";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True when `line` leaves a block comment open past its end (`foo = 1 /* …`). */
|
|
161
|
+
function opensBlockComment(line: string): boolean {
|
|
162
|
+
for (let i = 0; i < line.length; i++) {
|
|
163
|
+
const span = skipNonCode(line, i);
|
|
164
|
+
if (!span) continue;
|
|
165
|
+
if (line.startsWith("/*", i) && !line.includes("*/", i + 2)) return true;
|
|
166
|
+
i = span.end;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when the text after a header's `{` also closes that block. */
|
|
172
|
+
function closesOnLine(rest: string): boolean {
|
|
173
|
+
let depth = 1;
|
|
174
|
+
for (let i = 0; i < rest.length; i++) {
|
|
175
|
+
const span = skipNonCode(rest, i);
|
|
176
|
+
if (span) {
|
|
177
|
+
i = span.end;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (rest[i] === "{") depth++;
|
|
181
|
+
else if (rest[i] === "}" && --depth === 0) return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
104
184
|
}
|
|
105
185
|
|
|
106
186
|
/**
|
|
@@ -111,8 +191,21 @@ function literalValue(raw: string): boolean {
|
|
|
111
191
|
* parser is shared by every scanner and budgeted (see nativeScan.perf.test).
|
|
112
192
|
*/
|
|
113
193
|
export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
|
|
194
|
+
return findInLines(hcl, null);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Every secret-named literal in a `.tfvars` file. A tfvars file is nothing but
|
|
199
|
+
* assignments, so every one is read, at any depth, under the file's own name.
|
|
200
|
+
*/
|
|
201
|
+
export function findTfvarsSecrets(hcl: string, path: string): HardcodedSecret[] {
|
|
202
|
+
return findInLines(hcl, `tfvars input ${path}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** `fileHolder` set: the whole file is one holder (a tfvars file), with no blocks. */
|
|
206
|
+
function findInLines(hcl: string, fileHolder: string | null): HardcodedSecret[] {
|
|
114
207
|
const out: HardcodedSecret[] = [];
|
|
115
|
-
let holder: string | null =
|
|
208
|
+
let holder: string | null = fileHolder;
|
|
116
209
|
let heredoc: string | null = null;
|
|
117
210
|
let inComment = false;
|
|
118
211
|
const lines = hcl.split("\n");
|
|
@@ -132,12 +225,30 @@ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
|
|
|
132
225
|
continue;
|
|
133
226
|
}
|
|
134
227
|
if (trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
|
|
135
|
-
|
|
228
|
+
// a comment opened mid-line hides the lines after it; this line's code still counts
|
|
229
|
+
if (opensBlockComment(line)) inComment = true;
|
|
230
|
+
const header = fileHolder === null ? HEADER.exec(line) : null;
|
|
136
231
|
if (header) {
|
|
137
232
|
holder = `${header[1]}${(header[2] as string).trimEnd() ? ` ${(header[2] as string).trim()}` : ""}`;
|
|
233
|
+
// a block written on one line, `resource "a" "b" { password = "…" }`, carries its body
|
|
234
|
+
const rest = header[3] as string;
|
|
235
|
+
const opens = HEREDOC.exec(rest);
|
|
236
|
+
if (opens) heredoc = opens[1] as string;
|
|
237
|
+
if (!holder.startsWith("output ") && !holder.startsWith("terraform")) {
|
|
238
|
+
for (const pair of rest.matchAll(INLINE_PAIR)) {
|
|
239
|
+
const key = (pair[1] ?? pair[2]) as string;
|
|
240
|
+
const isVariable = holder.startsWith("variable ");
|
|
241
|
+
if (isVariable && key !== "default") continue;
|
|
242
|
+
const name = isVariable ? variableName(holder) : key;
|
|
243
|
+
if (isSecretName(name) && literalValue(name, pair[3] as string)) {
|
|
244
|
+
out.push({ name, holder, line: i + 1 });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (closesOnLine(rest)) holder = null;
|
|
138
249
|
continue;
|
|
139
250
|
}
|
|
140
|
-
if (line.startsWith("}")) {
|
|
251
|
+
if (fileHolder === null && line.startsWith("}")) {
|
|
141
252
|
holder = null;
|
|
142
253
|
continue;
|
|
143
254
|
}
|
|
@@ -150,21 +261,21 @@ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
|
|
|
150
261
|
const value = m[3] as string;
|
|
151
262
|
if (holder.startsWith("variable ")) {
|
|
152
263
|
// a variable's literal is its default, and the variable's name says what it holds
|
|
153
|
-
const variable =
|
|
154
|
-
if (name === "default" && isSecretName(variable) && literalValue(value)) {
|
|
264
|
+
const variable = variableName(holder);
|
|
265
|
+
if (name === "default" && isSecretName(variable) && literalValue(variable, value)) {
|
|
155
266
|
out.push({ name: variable, holder, line: i + 1 });
|
|
156
267
|
}
|
|
157
268
|
continue;
|
|
158
269
|
}
|
|
159
270
|
if (holder.startsWith("output ") || holder.startsWith("terraform")) continue;
|
|
160
|
-
if (isSecretName(name) && literalValue(value)) {
|
|
271
|
+
if (isSecretName(name) && literalValue(name, value)) {
|
|
161
272
|
out.push({ name, holder, line: i + 1 });
|
|
162
273
|
continue;
|
|
163
274
|
}
|
|
164
275
|
// an object written on one line holds its own pairs: `{ "DB_PASSWORD" = "…" }`
|
|
165
276
|
for (const pair of value.matchAll(INLINE_PAIR)) {
|
|
166
277
|
const key = (pair[1] ?? pair[2]) as string;
|
|
167
|
-
if (isSecretName(key) && literalValue(pair[3] as string)) {
|
|
278
|
+
if (isSecretName(key) && literalValue(key, pair[3] as string)) {
|
|
168
279
|
out.push({ name: key, holder, line: i + 1 });
|
|
169
280
|
}
|
|
170
281
|
}
|
|
@@ -172,11 +283,21 @@ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
|
|
|
172
283
|
return out;
|
|
173
284
|
}
|
|
174
285
|
|
|
175
|
-
/**
|
|
176
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Scan an already-read tree into native concerns. Pure; no I/O of its own.
|
|
288
|
+
* `tfvarsFiles` are the `.tfvars` inputs, read as assignments rather than blocks.
|
|
289
|
+
*/
|
|
290
|
+
export function scanHardcodedSecrets(
|
|
291
|
+
files: readonly SourceFile[],
|
|
292
|
+
tfvarsFiles: readonly SourceFile[] = [],
|
|
293
|
+
): Concern[] {
|
|
177
294
|
const concerns: Concern[] = [];
|
|
178
|
-
|
|
179
|
-
|
|
295
|
+
const hits = [
|
|
296
|
+
...files.map((f) => ({ path: f.path, found: findHardcodedSecrets(f.hcl) })),
|
|
297
|
+
...tfvarsFiles.map((f) => ({ path: f.path, found: findTfvarsSecrets(f.hcl, f.path) })),
|
|
298
|
+
];
|
|
299
|
+
for (const { path, found } of hits) {
|
|
300
|
+
for (const hit of found) {
|
|
180
301
|
concerns.push(
|
|
181
302
|
buildConcern({
|
|
182
303
|
source: NATIVE_SOURCE,
|
|
@@ -131,7 +131,8 @@ export const NATIVE_RULE_SCANNERS: readonly NativeRuleScanner[] = [
|
|
|
131
131
|
name: "hardcoded-secrets",
|
|
132
132
|
rules: [HARDCODED_SECRET_ARGUMENT],
|
|
133
133
|
// Per file: the argument's name and its literal are both on the line itself.
|
|
134
|
-
|
|
134
|
+
// The only rule that reads `.tfvars`: an input file holds values, not config.
|
|
135
|
+
scan: (ctx) => ({ concerns: scanHardcodedSecrets(ctx.files, ctx.tfvarsFiles) }),
|
|
135
136
|
},
|
|
136
137
|
{
|
|
137
138
|
name: "write-only",
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
describeOversize,
|
|
27
27
|
describeTruncation,
|
|
28
28
|
readTfTree,
|
|
29
|
+
readTfvarsTree,
|
|
29
30
|
readTofuTree,
|
|
30
31
|
type SourceTree,
|
|
31
32
|
} from "#app/mcp/terraform/tree";
|
|
@@ -74,6 +75,9 @@ export function scanNative(cwd: string, opts: NativeScanOptions = {}): ScannerOu
|
|
|
74
75
|
// walk over a suffix most repos do not use at all, and threading it through
|
|
75
76
|
// the context keeps the scanners uniform.
|
|
76
77
|
tofuFiles: readTofuTree(cwd).files,
|
|
78
|
+
// likewise only for the hardcoded-secret rule: a value kept out of the `.tf`
|
|
79
|
+
// source is most often committed here instead
|
|
80
|
+
tfvarsFiles: readTfvarsTree(cwd).files,
|
|
77
81
|
truncated: tree.truncated,
|
|
78
82
|
repeatedLiteralThreshold: opts.repeatedLiteralThreshold ?? DEFAULT_REPEAT_THRESHOLD,
|
|
79
83
|
});
|
|
@@ -31,14 +31,17 @@ import { describeMissingMoves, type PairAnalysis } from "#app/mcp/terraform/plan
|
|
|
31
31
|
import {
|
|
32
32
|
instanceKeysOf,
|
|
33
33
|
isModuleAddress,
|
|
34
|
+
moduleHops,
|
|
34
35
|
normaliseAddress,
|
|
35
36
|
qualify,
|
|
36
37
|
stripIndices,
|
|
38
|
+
stripModuleHops,
|
|
37
39
|
} from "#app/mcp/terraform/refactor/addresses";
|
|
38
40
|
import { type MovedBlock, moveId } from "#app/mcp/terraform/refactor/movedBlocks";
|
|
39
41
|
import {
|
|
40
42
|
describeMultiplicity,
|
|
41
43
|
enumerateInstanceKeys,
|
|
44
|
+
type Multiplicity,
|
|
42
45
|
type ResourceRef,
|
|
43
46
|
sameMultiplicity,
|
|
44
47
|
sameMultiplicityLevels,
|
|
@@ -384,10 +387,11 @@ function correspond(
|
|
|
384
387
|
* than sending a correct refactor away for a plan it does not need.
|
|
385
388
|
*
|
|
386
389
|
* Abstains (null) on anything it cannot read exactly: a chain of more than one
|
|
387
|
-
* repetition, an expression that is not a literal collection,
|
|
388
|
-
* that names no single instance
|
|
389
|
-
*
|
|
390
|
-
* no-op, and a mistyped key shows
|
|
390
|
+
* repetition, an expression that is not a literal collection, a declared block
|
|
391
|
+
* that names no single instance, or one whose key level cannot be located.
|
|
392
|
+
* Extra blocks naming keys the collection does not contain are NOT an error — a
|
|
393
|
+
* `moved` block whose source never existed is a no-op, and a mistyped key shows
|
|
394
|
+
* up as the missing one it left behind.
|
|
391
395
|
*/
|
|
392
396
|
function uncoveredInstanceKeys(p: Pairing): string[] | null {
|
|
393
397
|
const level = p.before.multiplicity;
|
|
@@ -400,25 +404,54 @@ function uncoveredInstanceKeys(p: Pairing): string[] | null {
|
|
|
400
404
|
// is their exact spelling.
|
|
401
405
|
const kind = level[0].kind;
|
|
402
406
|
const written = (k: string) => (kind === "count" ? k : `"${k}"`);
|
|
407
|
+
const fromAt = level[0].at;
|
|
408
|
+
const toAt = p.after.multiplicity[0]?.at;
|
|
409
|
+
if (fromAt === undefined || toAt === undefined) return null;
|
|
403
410
|
const declared = new Set<string>();
|
|
404
411
|
for (const blk of p.blocks) {
|
|
412
|
+
// a chain hop or a module-level block names an address neither tree
|
|
413
|
+
// attributes, so where its repetition sits is unknown
|
|
414
|
+
if (
|
|
415
|
+
stripIndices(normaliseAddress(blk.from)) !== p.before.address ||
|
|
416
|
+
stripIndices(normaliseAddress(blk.to)) !== p.after.address
|
|
417
|
+
)
|
|
418
|
+
return null;
|
|
405
419
|
// A relevelling repeats BOTH sides, at different levels: the instance is
|
|
406
420
|
// `aws_s3_bucket.data["logs"]` before and `module.data["logs"].…` after, so a
|
|
407
|
-
// block covers it only when each side names that same key
|
|
408
|
-
// alone (`aws_s3_bucket.data["logs"]` →
|
|
409
|
-
//
|
|
410
|
-
//
|
|
421
|
+
// block covers it only when each side names that same key, at the level that
|
|
422
|
+
// side repeats. A key on one side alone (`aws_s3_bucket.data["logs"]` →
|
|
423
|
+
// `module.data.aws_s3_bucket.this`), or on the wrong level
|
|
424
|
+
// (`module.data.aws_s3_bucket.this["logs"]`), points at an address the
|
|
425
|
+
// repetition does not have, and Terraform plans it as a destroy and a create.
|
|
411
426
|
const from = instanceKeysOf(blk.from);
|
|
412
427
|
const to = instanceKeysOf(blk.to);
|
|
413
428
|
// more than one key per side is a chain this proof does not read; no key on
|
|
414
429
|
// either side names no single instance
|
|
415
430
|
if (from.length > 1 || to.length > 1) return null;
|
|
416
431
|
if (from.length === 0 && to.length === 0) return null;
|
|
417
|
-
if (from.length
|
|
432
|
+
if (from.length !== 1 || to.length !== 1 || from[0] !== to[0]) continue;
|
|
433
|
+
const fromPlaced = keyAtLevel(blk.from, fromAt);
|
|
434
|
+
const toPlaced = keyAtLevel(blk.to, toAt);
|
|
435
|
+
// a side on the wrong level covers nothing, whatever the other side says
|
|
436
|
+
if (fromPlaced === false || toPlaced === false) continue;
|
|
437
|
+
if (fromPlaced === null || toPlaced === null) return null;
|
|
438
|
+
declared.add(from[0] as string);
|
|
418
439
|
}
|
|
419
440
|
return expected.filter((k) => !declared.has(written(k)));
|
|
420
441
|
}
|
|
421
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Whether the one instance key `address` writes sits on the level `at` that
|
|
445
|
+
* repeats, or null when that level cannot be located: a module repetition on a
|
|
446
|
+
* path through several calls is on one of them, and the chain does not say which.
|
|
447
|
+
*/
|
|
448
|
+
function keyAtLevel(address: string, at: NonNullable<Multiplicity["at"]>): boolean | null {
|
|
449
|
+
const onResource = instanceKeysOf(stripModuleHops(normaliseAddress(address))).length > 0;
|
|
450
|
+
if (at === "resource") return onResource;
|
|
451
|
+
if (onResource) return false;
|
|
452
|
+
return moduleHops(address).length === 1 ? true : null;
|
|
453
|
+
}
|
|
454
|
+
|
|
422
455
|
/**
|
|
423
456
|
* Compute the keys-free equivalence verdict from the before/after resource
|
|
424
457
|
* address sets and the `moved {}` blocks present in the after tree. Validate/fmt
|