infraweaver 0.3.11 → 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/cli.mjs +310 -231
- package/dist/index.js +289 -210
- package/dist/mcp/terraform/awsPrices.d.ts +1 -1
- package/dist/mcp/terraform/azurePrices.d.ts +1 -1
- package/dist/mcp/terraform/scanSession.d.ts +2 -0
- package/dist/mcp/terraform/verifySites.d.ts +48 -0
- package/package.json +1 -1
- package/src/mcp/terraform/awsPrices.ts +1 -1
- package/src/mcp/terraform/azurePrices.ts +23 -23
- package/src/mcp/terraform/concernResult.ts +2 -0
- package/src/mcp/terraform/scanSession.ts +6 -0
- package/src/mcp/terraform/tools/verifyRemediation.ts +23 -1
- package/src/mcp/terraform/verifySites.ts +106 -0
|
@@ -3,7 +3,7 @@ export declare const AWS_PRICE_REGION = "eu-west-2";
|
|
|
3
3
|
/** Currency of every price below. The bulk API quotes USD for every region. */
|
|
4
4
|
export declare const AWS_PRICE_CURRENCY = "USD";
|
|
5
5
|
/** ISO date the snapshot was captured — surfaced in cost.md so staleness is visible. */
|
|
6
|
-
export declare const AWS_PRICE_FETCHED_AT = "2026-
|
|
6
|
+
export declare const AWS_PRICE_FETCHED_AT = "2026-10-03";
|
|
7
7
|
/** meter key → list price per HOUR. */
|
|
8
8
|
export declare const AWS_HOURLY_PRICES: Record<string, number>;
|
|
9
9
|
/** meter key → list price per GB processed. Usage, so never part of a floor. */
|
|
@@ -3,6 +3,6 @@ export declare const AZURE_PRICE_REGION = "westeurope";
|
|
|
3
3
|
/** Currency of every price below. */
|
|
4
4
|
export declare const AZURE_PRICE_CURRENCY = "EUR";
|
|
5
5
|
/** ISO date the snapshot was captured — surfaced in cost.md so staleness is visible. */
|
|
6
|
-
export declare const AZURE_PRICE_FETCHED_AT = "2026-
|
|
6
|
+
export declare const AZURE_PRICE_FETCHED_AT = "2026-10-03";
|
|
7
7
|
/** meter key → list price per HOUR. */
|
|
8
8
|
export declare const AZURE_HOURLY_PRICES: Record<string, number>;
|
|
@@ -44,6 +44,8 @@ export interface ScanSession {
|
|
|
44
44
|
baselineFmtDriftFiles?: string[];
|
|
45
45
|
lastScanConcerns?: Concern[];
|
|
46
46
|
concernsById?: Map<string, Concern>;
|
|
47
|
+
siteById?: Map<string, string | null>;
|
|
48
|
+
firstSitesByKey?: Map<string, Set<string | null>>;
|
|
47
49
|
gateConcerns?: Concern[];
|
|
48
50
|
policyRules?: unknown[] | null;
|
|
49
51
|
policyGate?: PolicyGateResult;
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
import type { ScanSession } from "./scanSession.js";
|
|
22
|
+
import { type Concern } from "./types.js";
|
|
23
|
+
/** A reader that returns each file under `cwd` once, or null when unreadable. */
|
|
24
|
+
export declare function fileReader(cwd: string): (file: string) => string | null;
|
|
25
|
+
/** The resource a concern sits in, read from the file as it is now. */
|
|
26
|
+
export declare function siteOf(c: Concern, read: (file: string) => string | null): string | null;
|
|
27
|
+
/** Remember where each reported concern sat, and the first sites per key. */
|
|
28
|
+
export declare function recordConcernSites(session: ScanSession, concerns: Concern[], cwd: string): void;
|
|
29
|
+
export interface SiteRequest {
|
|
30
|
+
id: string;
|
|
31
|
+
/** the (scanner, rule, file) key */
|
|
32
|
+
key: string;
|
|
33
|
+
/** the resource address the finding sat in when reported, or null */
|
|
34
|
+
site: string | null;
|
|
35
|
+
file: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
export declare function resolvedAtSite(params: {
|
|
38
|
+
requested: readonly SiteRequest[];
|
|
39
|
+
/** every finding the re-scan reports, with the resource it sits in */
|
|
40
|
+
current: readonly {
|
|
41
|
+
key: string;
|
|
42
|
+
site: string | null;
|
|
43
|
+
}[];
|
|
44
|
+
/** per key, the resources the rule fired on in the first scan of the run */
|
|
45
|
+
firstSites: ReadonlyMap<string, ReadonlySet<string | null>>;
|
|
46
|
+
/** the resource addresses a file declares now */
|
|
47
|
+
declared: (file: string) => ReadonlySet<string>;
|
|
48
|
+
}): string[];
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ export const AWS_PRICE_REGION = "eu-west-2";
|
|
|
13
13
|
/** Currency of every price below. The bulk API quotes USD for every region. */
|
|
14
14
|
export const AWS_PRICE_CURRENCY = "USD";
|
|
15
15
|
/** ISO date the snapshot was captured — surfaced in cost.md so staleness is visible. */
|
|
16
|
-
export const AWS_PRICE_FETCHED_AT = "2026-
|
|
16
|
+
export const AWS_PRICE_FETCHED_AT = "2026-10-03";
|
|
17
17
|
|
|
18
18
|
/** meter key → list price per HOUR. */
|
|
19
19
|
export const AWS_HOURLY_PRICES: Record<string, number> = {
|
|
@@ -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
|
};
|
|
@@ -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 */
|
|
@@ -128,6 +129,7 @@ export function buildConcernResult(
|
|
|
128
129
|
fullBaseline.map((c) => concernKeyOf(c)),
|
|
129
130
|
);
|
|
130
131
|
recordConcernKeys(session, fullBaseline);
|
|
132
|
+
recordConcernSites(session, fullBaseline, opts.cwd);
|
|
131
133
|
// the files already unformatted at this (pre-fix) scan. terraform_validate
|
|
132
134
|
// subtracts these so PRE-EXISTING `terraform fmt` drift can't force a
|
|
133
135
|
// whole-file reformat into a scoped fix. Captured from the raw set (fmt
|
|
@@ -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
|
|
@@ -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];
|
|
@@ -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
|
+
}
|