@aaa-sentinel/iac-cost 0.3.1 → 0.4.0

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.
Files changed (3) hide show
  1. package/README.md +59 -0
  2. package/dist/cli.js +276 -76
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -7,6 +7,10 @@ internet is caught in review instead of after the deploy. With `iac-cost fix`, t
7
7
  your last scan on resources this Terraform manages come back to you as **pull requests**, not as
8
8
  console changes (see [Fixes as pull requests](#fixes-as-pull-requests)).
9
9
 
10
+ > **New in 0.4.0:** the `posture` subcommand prints how the security posture of an account changed
11
+ > over 30, 90 or 365 days, with an optional gate on new findings (see
12
+ > [Posture over time](#posture-over-time)). Nothing changes for `fix` or the existing command.
13
+
10
14
  > **Changed in 0.3.1:** the pull requests of `fix` pass `terraform fmt -check`: an added attribute is
11
15
  > aligned with its group (also around heredocs and after removing a block), as `terraform fmt` does.
12
16
 
@@ -543,6 +547,61 @@ pipelines:
543
547
 
544
548
  The commit is created through the Bitbucket API (`POST /src`), so the clone needs no push access.
545
549
 
550
+ ## Posture over time
551
+
552
+ `iac-cost posture` reads `GET /v1/security/posture-history` and prints, for one account and a
553
+ period of 30, 90 or 365 days:
554
+
555
+ - the Security Score from the first to the last day with a scan (the last scan of each day), or
556
+ why it is **not comparable** (the rule catalog or what could be evaluated changed in between);
557
+ - distinct findings **resolved** (gone in a scan that fully evaluated their rule) and **new**
558
+ (from rules that were already fully evaluated before);
559
+ - findings that cannot be compared (their rule was not fully evaluated in the previous scan, or
560
+ they come from an external tool) and findings gone without confirmation;
561
+ - the mean time to fix critical and high findings.
562
+
563
+ ```bash
564
+ export IAC_SECURITY_CLIENT_ID=… # OAuth2 app with the security:read scope
565
+ export IAC_SECURITY_CLIENT_SECRET=…
566
+ npx --yes @aaa-sentinel/iac-cost posture --account acc_01j… --days 90
567
+ ```
568
+
569
+ | Option | Default | |
570
+ | --- | --- | --- |
571
+ | `--account <id>` | `IAC_SECURITY_ACCOUNT_ID` | AAA Sentinel account id |
572
+ | `--days <30\|90\|365>` | `90` | Period |
573
+ | `--format <markdown\|text\|json>` | `markdown` | `json` prints the API response as is |
574
+ | `--fail-on-new <severity>` | — | Exit 1 when new findings at this severity or worse appeared in the period |
575
+
576
+ Exit codes: `1` only when `--fail-on-new` trips; `2` for a usage error (an option without a value,
577
+ a stray argument), missing credentials or, with the gate, a 4xx from the API (wrong scope or
578
+ account); `0` otherwise, also when the API does not answer (a warning explains why). Findings that
579
+ cannot be compared with the previous scan do not count for the gate.
580
+
581
+ A weekly job that writes the summary to the run page:
582
+
583
+ ```yaml
584
+ name: Security posture
585
+ on:
586
+ schedule:
587
+ - cron: '0 7 * * 1'
588
+ workflow_dispatch:
589
+
590
+ jobs:
591
+ posture:
592
+ runs-on: ubuntu-latest
593
+ steps:
594
+ - uses: actions/setup-node@v4
595
+ with:
596
+ node-version: 22
597
+ - name: Posture over the last 90 days
598
+ env:
599
+ IAC_SECURITY_CLIENT_ID: ${{ secrets.IAC_SECURITY_CLIENT_ID }}
600
+ IAC_SECURITY_CLIENT_SECRET: ${{ secrets.IAC_SECURITY_CLIENT_SECRET }}
601
+ run: |
602
+ npx --yes @aaa-sentinel/iac-cost posture --account ${{ vars.AAA_SENTINEL_ACCOUNT_ID }} --days 90 --fail-on-new critical >> "$GITHUB_STEP_SUMMARY"
603
+ ```
604
+
546
605
  ## Why there is no comment
547
606
 
548
607
  | What you see | Why |
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ var FORMATS = ["markdown", "text", "json"];
14
14
  var SEVERITIES = ["critical", "high", "medium", "low"];
15
15
  var USAGE = `Usage: iac-cost [plan.json] [options]
16
16
  iac-cost fix [plan.json] --account <accountId> [options] (see iac-cost fix --help)
17
+ iac-cost posture --account <accountId> [options] (see iac-cost posture --help)
17
18
 
18
19
  Reads \`terraform show -json\` output from the file or from stdin.
19
20
 
@@ -1724,9 +1725,9 @@ var Doc = class {
1724
1725
  return;
1725
1726
  }
1726
1727
  const content = arg;
1727
- const lines = content.split("\n").filter((x) => x);
1728
- const minIndent = Math.min(...lines.map((x) => x.length - x.trimStart().length));
1729
- const dedented = lines.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x);
1728
+ const lines2 = content.split("\n").filter((x) => x);
1729
+ const minIndent = Math.min(...lines2.map((x) => x.length - x.trimStart().length));
1730
+ const dedented = lines2.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x);
1730
1731
  for (const line of dedented) {
1731
1732
  this.content.push(line);
1732
1733
  }
@@ -6409,8 +6410,8 @@ function aggregateEstimates(estimates) {
6409
6410
  const billable = pricedResources + unpricedResources;
6410
6411
  const ratio = billable === 0 ? 1 : pricedResources / billable;
6411
6412
  const sufficientForTotal = ratio >= IAC_MIN_COVERAGE_RATIO;
6412
- const total = [...priced.values()].reduce((acc, i) => acc + i.monthlyDeltaUsd, 0);
6413
- const totalMonthlyDeltaUsd = sufficientForTotal ? round2(total) : null;
6413
+ const total2 = [...priced.values()].reduce((acc, i) => acc + i.monthlyDeltaUsd, 0);
6414
+ const totalMonthlyDeltaUsd = sufficientForTotal ? round2(total2) : null;
6414
6415
  const first = estimates[0]?.displayCurrency;
6415
6416
  const display = first && estimates.every((e) => e.displayCurrency?.code === first.code && e.displayCurrency.rate === first.rate) ? first : null;
6416
6417
  for (const item of priced.values()) {
@@ -8285,6 +8286,7 @@ var RULE_TITLES = {
8285
8286
  SEC_AZURE_LOGGING_SIN_DIAGNOSTICO: "Subscription does not export Activity Log",
8286
8287
  SEC_GCP_NETWORK_ADMIN_ABIERTO: "Firewall with admin ports open to the internet",
8287
8288
  SEC_GCP_EXPOSURE_SQL_PUBLICO: "Cloud SQL authorized from 0.0.0.0/0",
8289
+ SEC_GCP_EXPOSURE_GCS_PUBLICO: "Public Cloud Storage bucket (allUsers or allAuthenticatedUsers)",
8288
8290
  SEC_GCP_ENCRYPTION_SQL_SIN_CMEK: "Cloud SQL without CMEK",
8289
8291
  SEC_GCP_SECRETS_FUNCTION_ENV: "Secret in a Cloud Function environment variable",
8290
8292
  SEC_GCP_IAM_ROL_PRIMITIVO: "Primitive Owner or Editor role on the project",
@@ -8779,7 +8781,7 @@ function carry(findingId, fix2, state) {
8779
8781
  }
8780
8782
  async function runFix(options, deps) {
8781
8783
  const warnings = [];
8782
- const lines = [];
8784
+ const lines2 = [];
8783
8785
  const reports = [];
8784
8786
  const { host } = deps;
8785
8787
  let input;
@@ -8787,7 +8789,7 @@ async function runFix(options, deps) {
8787
8789
  input = await deps.api.load();
8788
8790
  } catch (error2) {
8789
8791
  warnings.push(`could not read the open findings, nothing to fix: ${error2.message}`);
8790
- return { stdout: render(options, lines, deps.accountId, null), warnings, lines, reported: 0 };
8792
+ return { stdout: render(options, lines2, deps.accountId, null), warnings, lines: lines2, reported: 0 };
8791
8793
  }
8792
8794
  if (input.truncated) warnings.push("the account has more open findings than the API returns (500 most severe): the rest are left for a later run");
8793
8795
  let publicRepo = false;
@@ -8818,7 +8820,7 @@ async function runFix(options, deps) {
8818
8820
  }
8819
8821
  if (state !== "merged") {
8820
8822
  reports.push(carry(finding.findingId, existing, state));
8821
- lines.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: existing.address, outcome: state, pullRequest: existing.pullRequest.url });
8823
+ lines2.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: existing.address, outcome: state, pullRequest: existing.pullRequest.url });
8822
8824
  continue;
8823
8825
  }
8824
8826
  existing.state = "merged";
@@ -8840,7 +8842,7 @@ async function runFix(options, deps) {
8840
8842
  const merged = existing && sameRepo && existing.state === "merged" && existing.pullRequest ? existing : null;
8841
8843
  if (merged && outcome.kind !== "patch") {
8842
8844
  reports.push(carry(finding.findingId, merged, "merged"));
8843
- lines.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: merged.address, outcome: "merged", ...merged.pullRequest ? { pullRequest: merged.pullRequest.url } : {} });
8845
+ lines2.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: merged.address, outcome: "merged", ...merged.pullRequest ? { pullRequest: merged.pullRequest.url } : {} });
8844
8846
  continue;
8845
8847
  }
8846
8848
  const base = { findingId: finding.findingId, ruleId: finding.ruleId, address: outcome.address };
@@ -8853,18 +8855,18 @@ async function runFix(options, deps) {
8853
8855
  ...outcome.file !== void 0 ? { file: outcome.file } : {},
8854
8856
  ...outcome.attribute !== void 0 ? { attribute: outcome.attribute } : {}
8855
8857
  });
8856
- lines.push({ ...base, outcome: "suggested", reason: outcome.reason, ...outcome.file !== void 0 ? { file: outcome.file } : {} });
8858
+ lines2.push({ ...base, outcome: "suggested", reason: outcome.reason, ...outcome.file !== void 0 ? { file: outcome.file } : {} });
8857
8859
  continue;
8858
8860
  }
8859
8861
  if (outcome.kind === "fixed_in_code") {
8860
8862
  reports.push({ findingId: finding.findingId, state: "pending", reason: "fixed_in_code", address: outcome.address, file: outcome.file, attribute: outcome.attribute });
8861
- lines.push({ ...base, outcome: "pending", reason: "fixed_in_code", file: outcome.file });
8863
+ lines2.push({ ...base, outcome: "pending", reason: "fixed_in_code", file: outcome.file });
8862
8864
  continue;
8863
8865
  }
8864
8866
  const patch = outcome;
8865
8867
  const pending = (reason) => {
8866
8868
  reports.push({ findingId: finding.findingId, state: "pending", reason, address: patch.address, file: patch.file, attribute: patch.attribute });
8867
- lines.push({ ...base, outcome: "pending", reason, file: patch.file, changes: patch.changes });
8869
+ lines2.push({ ...base, outcome: "pending", reason, file: patch.file, changes: patch.changes });
8868
8870
  };
8869
8871
  if (options.dryRun || !host) {
8870
8872
  pending("dry_run");
@@ -8872,7 +8874,7 @@ async function runFix(options, deps) {
8872
8874
  }
8873
8875
  if (host.unsupported) {
8874
8876
  reports.push({ findingId: finding.findingId, state: "suggested", reason: "unsupported_host", address: patch.address, file: patch.file, attribute: patch.attribute });
8875
- lines.push({ ...base, outcome: "suggested", reason: "unsupported_host", file: patch.file, changes: patch.changes });
8877
+ lines2.push({ ...base, outcome: "suggested", reason: "unsupported_host", file: patch.file, changes: patch.changes });
8876
8878
  continue;
8877
8879
  }
8878
8880
  if (publicRepo) {
@@ -8896,7 +8898,7 @@ async function runFix(options, deps) {
8896
8898
  repository: host.repository,
8897
8899
  pullRequest: { url: pr.url, number: pr.number }
8898
8900
  });
8899
- lines.push({ ...base, outcome: state, file: patch.file, pullRequest: pr.url, changes: patch.changes });
8901
+ lines2.push({ ...base, outcome: state, file: patch.file, pullRequest: pr.url, changes: patch.changes });
8900
8902
  };
8901
8903
  try {
8902
8904
  if (await host.branchExists(branch)) {
@@ -8946,7 +8948,7 @@ async function runFix(options, deps) {
8946
8948
  warnings.push(`could not record the fixes in AAA Sentinel: ${error2.message}`);
8947
8949
  }
8948
8950
  }
8949
- return { stdout: render(options, lines, deps.accountId, input.scanAt), warnings, lines, reported };
8951
+ return { stdout: render(options, lines2, deps.accountId, input.scanAt), warnings, lines: lines2, reported };
8950
8952
  }
8951
8953
  var SkipFinding = class extends Error {
8952
8954
  };
@@ -8965,11 +8967,11 @@ var OUTCOME_TEXT = {
8965
8967
  pending: "pending",
8966
8968
  suggested: "change suggested, no pull request"
8967
8969
  };
8968
- function render(options, lines, accountId, scanAt) {
8969
- if (options.format === "json") return JSON.stringify({ formatVersion: "1", accountId, scanAt, dryRun: options.dryRun, fixes: lines }, null, 2);
8970
- if (lines.length === 0) return `No open finding of account ${accountId} matches a resource of this plan.`;
8970
+ function render(options, lines2, accountId, scanAt) {
8971
+ if (options.format === "json") return JSON.stringify({ formatVersion: "1", accountId, scanAt, dryRun: options.dryRun, fixes: lines2 }, null, 2);
8972
+ if (lines2.length === 0) return `No open finding of account ${accountId} matches a resource of this plan.`;
8971
8973
  const out = [`Security fixes for account ${accountId}${options.dryRun ? " (dry run: nothing opened or recorded)" : ""}`];
8972
- for (const line of lines) {
8974
+ for (const line of lines2) {
8973
8975
  const detail = [line.reason, line.file, line.pullRequest].filter(Boolean).join(", ");
8974
8976
  out.push(` ${line.address} \u2014 ${title(line.ruleId)}: ${OUTCOME_TEXT[line.outcome]}${detail ? ` (${detail})` : ""}`);
8975
8977
  for (const change of line.changes ?? []) out.push(` ${change}`);
@@ -9078,6 +9080,168 @@ ${FIX_USAGE}`);
9078
9080
  return { stdout: result.stdout, warnings: [...warnings, ...result.warnings], exitCode: 0 };
9079
9081
  }
9080
9082
 
9083
+ // src/posture/posture.ts
9084
+ import { parseArgs as parseArgs3 } from "node:util";
9085
+ var POSTURE_USAGE = `Usage: iac-cost posture --account <accountId> [options]
9086
+
9087
+ Prints how the security posture of an AAA Sentinel account changed over a
9088
+ period: Score from the first to the last day with a scan, distinct findings
9089
+ resolved and new, mean time to fix, and catalog or coverage changes that make
9090
+ the Score not comparable.
9091
+
9092
+ Options:
9093
+ --account <accountId> AAA Sentinel account id (acc_\u2026), or IAC_SECURITY_ACCOUNT_ID
9094
+ --days <30|90|365> Period (default: 90)
9095
+ --format <markdown|text|json>
9096
+ Output format (default: markdown)
9097
+ --fail-on-new <critical|high|medium|low>
9098
+ Exit 1 when new findings at this severity or worse
9099
+ appeared in the period (findings that cannot be
9100
+ compared with the previous scan do not count).
9101
+ With the gate, a 4xx from the API (scope, account)
9102
+ exits 2; our outage still exits 0.
9103
+ -h, --help Show this help
9104
+
9105
+ Environment:
9106
+ IAC_SECURITY_CLIENT_ID / IAC_SECURITY_CLIENT_SECRET OAuth2 app with the security:read scope`;
9107
+ var SEVERITIES2 = ["critical", "high", "medium", "low"];
9108
+ var DAYS = [30, 90, 365];
9109
+ var FORMATS2 = ["markdown", "text", "json"];
9110
+ var TIMEOUT_MS3 = 35e3;
9111
+ var APP_URL = "https://app.aaa-sentinel.com";
9112
+ var total = (c) => SEVERITIES2.reduce((n, s) => n + (c[s] ?? 0), 0);
9113
+ var bySeverity = (c) => SEVERITIES2.filter((s) => (c[s] ?? 0) > 0).map((s) => `${c[s]} ${s}`).join(", ");
9114
+ var REASONS = {
9115
+ catalog_changed: "the rule catalog changed",
9116
+ coverage_changed: "what could be evaluated changed",
9117
+ not_compared: "a scan could not be compared with the previous one",
9118
+ scored_changed: "the number of scoring rules changed",
9119
+ partial: "one end was read incompletely"
9120
+ };
9121
+ function scoreLine(summary) {
9122
+ const from = summary.from?.score;
9123
+ const to = summary.to?.score;
9124
+ if (from === null || from === void 0 || to === null || to === void 0) return "no comparable Score in the period";
9125
+ if (!summary.comparable) {
9126
+ const why = summary.notComparableReasons.map((r) => REASONS[r] ?? r).join("; ");
9127
+ return `${from} \u2192 ${to}, not comparable (${why})`;
9128
+ }
9129
+ if (to > from) return `up from ${from} to ${to}`;
9130
+ if (to < from) return `down from ${from} to ${to}`;
9131
+ return `steady at ${to}`;
9132
+ }
9133
+ function lines(history) {
9134
+ const s = history.summary;
9135
+ const fix2 = ["critical", "high"].filter((sev) => s.meanFixDays[sev] !== null).map((sev) => `${sev} ${s.meanFixDays[sev]} d`);
9136
+ const out = [
9137
+ ["Score", scoreLine(s)],
9138
+ ["Resolved", `${total(s.resolved)}${total(s.resolved) ? ` (${bySeverity(s.resolved)})` : ""}`],
9139
+ ["New", `${total(s.opened)}${total(s.opened) ? ` (${bySeverity(s.opened)})` : ""}`]
9140
+ ];
9141
+ if (total(s.surfaced)) out.push(["Not comparable", `${total(s.surfaced)} new findings whose rule was not fully evaluated before`]);
9142
+ if (total(s.unverified)) out.push(["Unverified", `${total(s.unverified)} gone without confirmation`]);
9143
+ out.push(["Mean time to fix", fix2.length ? fix2.join(", ") : "no critical or high finding fixed with a known date"]);
9144
+ out.push(["Scans", String(s.scans)]);
9145
+ return out;
9146
+ }
9147
+ function renderPosture(history, format) {
9148
+ const enlace = `${APP_URL}/posture?accountId=${encodeURIComponent(history.accountId)}`;
9149
+ const notas = [];
9150
+ if (history.points.length < 2) notas.push("The trend appears once the period has scans on at least two different days.");
9151
+ if (history.acceptedRisks) notas.push("The account has accepted risks: each day keeps the Score computed at the time.");
9152
+ if (history.summary.notCompared) notas.push(`${history.summary.notCompared} scan(s) could not be compared with the previous one.`);
9153
+ if (history.summary.changesTruncated) notas.push("A scan had more changes than are stored: the figures are a minimum.");
9154
+ if (history.truncated) notas.push("The period has more scans than fit: only the most recent ones are counted.");
9155
+ const titulo = `Security posture, last ${history.days} days`;
9156
+ if (format === "text") {
9157
+ return [titulo, ...lines(history).map(([k, v]) => ` ${k}: ${v}`), ...notas.map((n) => ` ${n}`), ` ${enlace}`].join("\n");
9158
+ }
9159
+ return [
9160
+ `### ${titulo}`,
9161
+ "",
9162
+ "| | |",
9163
+ "|---|---|",
9164
+ ...lines(history).map(([k, v]) => `| ${k} | ${v} |`),
9165
+ "",
9166
+ ...notas.map((n) => `> ${n}`),
9167
+ ...notas.length ? [""] : [],
9168
+ `[Open in AAA Sentinel](${enlace})`
9169
+ ].join("\n");
9170
+ }
9171
+ function newAtOrAbove(summary, severity) {
9172
+ return SEVERITIES2.slice(0, SEVERITIES2.indexOf(severity) + 1).reduce((n, s) => n + (summary.opened[s] ?? 0), 0);
9173
+ }
9174
+ async function fetchPostureHistory(config2) {
9175
+ const fetchImpl = config2.fetchImpl ?? fetch;
9176
+ const token = await fetchToken(config2, "security:read");
9177
+ const url = `${config2.baseUrl}/v1/security/posture-history?accountId=${encodeURIComponent(config2.accountId)}&days=${config2.days}`;
9178
+ const response = await fetchImpl(url, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(TIMEOUT_MS3) });
9179
+ if (!response.ok) throw new Error(`posture_history_http_${response.status}`);
9180
+ return await response.json();
9181
+ }
9182
+ async function runPostureCli(argv, io) {
9183
+ const warnings = [];
9184
+ const fail = (message) => ({ stdout: null, warnings: [...warnings, message], exitCode: 2 });
9185
+ const { values, positionals } = parseArgs3({
9186
+ args: [...argv],
9187
+ strict: false,
9188
+ allowPositionals: true,
9189
+ options: {
9190
+ account: { type: "string" },
9191
+ days: { type: "string" },
9192
+ format: { type: "string" },
9193
+ "fail-on-new": { type: "string" },
9194
+ help: { type: "boolean", short: "h" }
9195
+ }
9196
+ });
9197
+ const known = /* @__PURE__ */ new Set(["account", "days", "format", "fail-on-new", "help", "h"]);
9198
+ for (const name of Object.keys(values)) {
9199
+ if (known.has(name)) continue;
9200
+ if (/^fail[-_]?on/i.test(name)) return fail(`unknown option --${name}: the gate is spelled --fail-on-new`);
9201
+ warnings.push(`unknown option --${name} ignored`);
9202
+ }
9203
+ if (values.help === true || values.h === true) return { stdout: POSTURE_USAGE, warnings, exitCode: 0 };
9204
+ for (const name of ["account", "days", "format", "fail-on-new"]) {
9205
+ const v = values[name];
9206
+ if (v === true || typeof v === "string" && v.startsWith("-")) return fail(`--${name} needs a value`);
9207
+ }
9208
+ if (positionals.length > 0) return fail(`unexpected argument ${positionals.join(" ")}`);
9209
+ const account = (typeof values.account === "string" ? values.account : io.env["IAC_SECURITY_ACCOUNT_ID"])?.trim();
9210
+ if (!account) return fail(`posture needs --account <accountId>
9211
+
9212
+ ${POSTURE_USAGE}`);
9213
+ const days = values.days === void 0 ? 90 : Number(values.days);
9214
+ if (!DAYS.includes(days)) return fail("--days must be 30, 90 or 365");
9215
+ const format = values.format === void 0 ? "markdown" : values.format;
9216
+ if (typeof format !== "string" || !FORMATS2.includes(format)) return fail(`--format must be one of ${FORMATS2.join(", ")}`);
9217
+ let gate = null;
9218
+ if (values["fail-on-new"] !== void 0) {
9219
+ const raw = typeof values["fail-on-new"] === "string" ? values["fail-on-new"].trim().toLowerCase() : "";
9220
+ if (!SEVERITIES2.includes(raw)) return fail(`--fail-on-new must be one of ${SEVERITIES2.join(", ")}`);
9221
+ gate = raw;
9222
+ }
9223
+ const baseUrl = resolveBaseUrl(io.env["IAC_COST_BASE_URL"] ?? "https://api.aaa-sentinel.com");
9224
+ if (baseUrl === null) return fail("IAC_COST_BASE_URL must be an https URL");
9225
+ const clientId = io.env["IAC_SECURITY_CLIENT_ID"];
9226
+ const clientSecret = io.env["IAC_SECURITY_CLIENT_SECRET"];
9227
+ if (!clientId || !clientSecret) return fail("IAC_SECURITY_CLIENT_ID/IAC_SECURITY_CLIENT_SECRET are not set: create an app with the security:read scope");
9228
+ let history;
9229
+ try {
9230
+ history = await fetchPostureHistory({ baseUrl, clientId, clientSecret, accountId: account, days, ...io.fetchImpl ? { fetchImpl: io.fetchImpl } : {} });
9231
+ } catch (error2) {
9232
+ const mensaje = error2.message;
9233
+ if (gate && /_http_4(00|01|03|04)$/.test(mensaje)) return fail(`could not read the posture history (${mensaje}): check the app scope (security:read) and the account id`);
9234
+ return { stdout: null, warnings: [...warnings, `could not read the posture history, skipping: ${mensaje}`], exitCode: 0 };
9235
+ }
9236
+ if (history.access !== "full") {
9237
+ return { stdout: null, warnings: [...warnings, "the account does not have the security add-on: no posture history to show"], exitCode: 0 };
9238
+ }
9239
+ const nuevos = gate ? newAtOrAbove(history.summary, gate) : 0;
9240
+ const stdout = format === "json" ? JSON.stringify(history, null, 2) : renderPosture(history, format);
9241
+ if (gate && nuevos > 0) warnings.push(`${nuevos} new finding(s) at ${gate} or worse in the last ${days} days (--fail-on-new ${gate})`);
9242
+ return { stdout, warnings, exitCode: gate && nuevos > 0 ? 1 : 0 };
9243
+ }
9244
+
9081
9245
  // src/terraform-plan.ts
9082
9246
  function actionOf2(actions) {
9083
9247
  if (actions.includes("create") && actions.includes("delete")) return "replace";
@@ -9169,51 +9333,51 @@ function actionVerb(action) {
9169
9333
  }
9170
9334
  function renderComment(estimate) {
9171
9335
  const { coverage } = estimate;
9172
- const lines = [COMMENT_MARKER, "### \u{1F4B2} Infrastructure cost estimate", ""];
9173
- lines.push(
9336
+ const lines2 = [COMMENT_MARKER, "### \u{1F4B2} Infrastructure cost estimate", ""];
9337
+ lines2.push(
9174
9338
  `**Coverage: ${coverage.pricedResources} of ${coverage.pricedResources + coverage.unpricedResources} billable resources priced** (${coverage.freeResources} more have no cost by design).`
9175
9339
  );
9176
- lines.push("");
9340
+ lines2.push("");
9177
9341
  const display = estimate.displayCurrency?.approximate ? estimate.displayCurrency : null;
9178
9342
  const delta = (usd, converted) => display && typeof converted === "number" ? fmtConverted(converted, display.code) : fmtUsd(usd);
9179
9343
  if (estimate.totalMonthlyDeltaUsd !== null) {
9180
- const total = delta(estimate.totalMonthlyDeltaUsd, estimate.totalMonthlyDelta);
9181
- lines.push(
9182
- display ? `**Estimated change: ${total}** (list price, ${fmtUsd(estimate.totalMonthlyDeltaUsd)} in USD)` : `**Estimated change: ${total}** (list price, USD)`
9344
+ const total2 = delta(estimate.totalMonthlyDeltaUsd, estimate.totalMonthlyDelta);
9345
+ lines2.push(
9346
+ display ? `**Estimated change: ${total2}** (list price, ${fmtUsd(estimate.totalMonthlyDeltaUsd)} in USD)` : `**Estimated change: ${total2}** (list price, USD)`
9183
9347
  );
9184
9348
  } else {
9185
- lines.push(
9349
+ lines2.push(
9186
9350
  `_Coverage is below the threshold needed to publish a total \u2014 see the breakdown below instead of a number that wouldn't hold up._`
9187
9351
  );
9188
9352
  }
9189
- lines.push("");
9190
- lines.push(
9353
+ lines2.push("");
9354
+ lines2.push(
9191
9355
  display ? `> These are **list prices**, not what you actually pay. Cloud providers publish them in USD; the amounts shown are **approximate**, converted to ${display.code} at the ECB reference rate${display.rateDate ? ` of ${display.rateDate}` : ""} (1 USD = ${display.rate} ${display.code}). ` + LIST_PRICE_CAVEAT : `> These are **list prices in USD**, not what you actually pay. Cloud providers set their list prices in USD, so showing this in another billing currency would be an estimate on top of an estimate. ${LIST_PRICE_CAVEAT}`
9192
9356
  );
9193
9357
  if (estimate.priced.length > 0) {
9194
- lines.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
9358
+ lines2.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
9195
9359
  for (const item of estimate.priced) {
9196
- lines.push(
9360
+ lines2.push(
9197
9361
  `| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${delta(item.monthlyDeltaUsd, item.monthlyDelta)} |`
9198
9362
  );
9199
9363
  }
9200
9364
  }
9201
9365
  if (estimate.unpriced.length > 0) {
9202
- lines.push("", "<details><summary>Not priced (" + coverage.unpricedResources + " resources)</summary>", "");
9203
- lines.push("| Resource | Change | Qty | Why |", "|---|---|---|---|");
9366
+ lines2.push("", "<details><summary>Not priced (" + coverage.unpricedResources + " resources)</summary>", "");
9367
+ lines2.push("| Resource | Change | Qty | Why |", "|---|---|---|---|");
9204
9368
  for (const item of estimate.unpriced) {
9205
- lines.push(`| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${reasonText(item.reason)} |`);
9369
+ lines2.push(`| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${reasonText(item.reason)} |`);
9206
9370
  }
9207
- lines.push("", "</details>");
9371
+ lines2.push("", "</details>");
9208
9372
  }
9209
9373
  const truncated = estimate.catalogs.filter((c) => c.truncated);
9210
9374
  if (truncated.length > 0) {
9211
- lines.push(
9375
+ lines2.push(
9212
9376
  "",
9213
9377
  `\u26A0\uFE0F The price catalog for ${truncated.map((c) => c.region).join(", ")} was truncated by pagination: some prices may be missing.`
9214
9378
  );
9215
9379
  }
9216
- return lines.join("\n");
9380
+ return lines2.join("\n");
9217
9381
  }
9218
9382
 
9219
9383
  // src/security/drift.ts
@@ -11350,7 +11514,7 @@ var AZURE_SECURITY_RULES = [
11350
11514
  var minus = (s) => typeof s === "string" ? s.toLowerCase() : "";
11351
11515
  var GCP_SECURITY_COMBOS = [
11352
11516
  {
11353
- // Abierta a internet, sin una conexión en 7 días y facturando: se paga por una puerta que nadie usa.
11517
+ // Abierta a internet, sin uso en 7 días y facturando: se paga por una puerta que nadie usa.
11354
11518
  id: "COMBO_GCP_PUBLIC_SQL_IDLE",
11355
11519
  provider: "gcp",
11356
11520
  severity: "high",
@@ -11420,6 +11584,11 @@ var GCP_COMPUTE_CHECKS = {
11420
11584
  var GCP_SQL_CHECKS = {
11421
11585
  instances: "gcp:sql:instances"
11422
11586
  };
11587
+ var GCP_STORAGE_CHECKS = {
11588
+ buckets: "gcp:storage:buckets",
11589
+ // storage.buckets.getIamPolicy no está en roles/viewer ni en roles/storage.viewer: lo trae roles/iam.securityReviewer.
11590
+ iamPolicy: "gcp:storage:buckets.getIamPolicy"
11591
+ };
11423
11592
  var GCP_FUNCTIONS_CHECKS = {
11424
11593
  list: "gcp:functions:list"
11425
11594
  };
@@ -11494,6 +11663,28 @@ function evaluate7(observation) {
11494
11663
  evidence: { authorizedNetworks: nets }
11495
11664
  };
11496
11665
  }
11666
+ function gcsPublicoEvaluate(observation) {
11667
+ if (observation.attributes.publicAccessPrevention === "enforced") return { status: "ok" };
11668
+ const bindings = observation.attributes.publicBindings;
11669
+ if (!Array.isArray(bindings)) {
11670
+ return { status: "unknown", check: GCP_STORAGE_CHECKS.iamPolicy, attribute: "publicBindings" };
11671
+ }
11672
+ if (bindings.length === 0) return { status: "ok" };
11673
+ return {
11674
+ status: "finding",
11675
+ evidence: { publicBindings: bindings, publicAccessPrevention: observation.attributes.publicAccessPrevention ?? null }
11676
+ };
11677
+ }
11678
+ var gcsPublico = {
11679
+ id: "SEC_GCP_EXPOSURE_GCS_PUBLICO",
11680
+ scope: "resource",
11681
+ category: "security_exposure",
11682
+ severity: "critical",
11683
+ provider: "gcp",
11684
+ kinds: ["gcs_bucket"],
11685
+ requires: [GCP_STORAGE_CHECKS.buckets, GCP_STORAGE_CHECKS.iamPolicy],
11686
+ evaluate: gcsPublicoEvaluate
11687
+ };
11497
11688
  var sqlPublico2 = {
11498
11689
  id: "SEC_GCP_EXPOSURE_SQL_PUBLICO",
11499
11690
  scope: "resource",
@@ -11910,6 +12101,7 @@ var GCP_SECURITY_RULES = [
11910
12101
  firewallAdminAbierto,
11911
12102
  politicaAdminAbierta,
11912
12103
  sqlPublico2,
12104
+ gcsPublico,
11913
12105
  discoSinCmek,
11914
12106
  sqlSinCmek,
11915
12107
  secretoEnFunction,
@@ -11953,7 +12145,8 @@ var UNCOVERED_TYPES = /* @__PURE__ */ new Set([
11953
12145
  "google_compute_region_network_firewall_policy_rule",
11954
12146
  "google_logging_project_sink",
11955
12147
  "google_project_iam_audit_config",
11956
- // Sin regla viva todavía: un bucket de GCS público no se juzga ni antes ni después de desplegar.
12148
+ // SEC_GCP_EXPOSURE_GCS_PUBLICO juzga el bucket desplegado; el plan reparte su política en tres tipos que aún no se cruzan.
12149
+ "google_storage_bucket",
11957
12150
  "google_storage_bucket_iam_member",
11958
12151
  "google_storage_bucket_iam_binding",
11959
12152
  "google_storage_bucket_iam_policy"
@@ -12030,14 +12223,14 @@ function evaluatePlan(plan) {
12030
12223
  }))].sort();
12031
12224
  const accountRules = ALL_RULES.filter((rule) => rule.scope === "account" && providers.includes(rule.provider)).map((rule) => rule.id);
12032
12225
  const uncoveredTypes = [...new Set(index.resources.map((resource) => resource.type).filter((type) => UNCOVERED_TYPES.has(type)))].sort();
12033
- const bySeverity = (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.address.localeCompare(b.address);
12226
+ const bySeverity2 = (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.address.localeCompare(b.address);
12034
12227
  return {
12035
12228
  report: {
12036
12229
  providers,
12037
12230
  resourcesEvaluated: new Set(observations.map((observation) => observation.address)).size,
12038
- findings: planFindings.sort(bySeverity),
12039
- combos: combos.sort(bySeverity),
12040
- notEvaluable: notEvaluable.sort(bySeverity),
12231
+ findings: planFindings.sort(bySeverity2),
12232
+ combos: combos.sort(bySeverity2),
12233
+ notEvaluable: notEvaluable.sort(bySeverity2),
12041
12234
  accountRules,
12042
12235
  uncoveredTypes,
12043
12236
  passed
@@ -12053,7 +12246,7 @@ function blockingCount(report, threshold) {
12053
12246
 
12054
12247
  // src/security/render.ts
12055
12248
  import { createHash as createHash2 } from "node:crypto";
12056
- var SEVERITIES2 = ["critical", "high", "medium", "low"];
12249
+ var SEVERITIES3 = ["critical", "high", "medium", "low"];
12057
12250
  var BADGE = { critical: "\u{1F534} critical", high: "\u{1F7E0} high", medium: "\u{1F7E1} medium", low: "\u26AA low" };
12058
12251
  var GAP_TEXT = {
12059
12252
  known_after_apply: "value only known after apply",
@@ -12085,7 +12278,7 @@ function evidenceSummary(finding) {
12085
12278
  return parts.join("; ");
12086
12279
  }
12087
12280
  function counts(report) {
12088
- return SEVERITIES2.map((severity) => `${report.findings.filter((f) => f.severity === severity).length} ${severity}`).join(", ");
12281
+ return SEVERITIES3.map((severity) => `${report.findings.filter((f) => f.severity === severity).length} ${severity}`).join(", ");
12089
12282
  }
12090
12283
  function displayAddress(address) {
12091
12284
  return address.replace(/\["((?:[^"\\]|\\.)*)"\]/g, (_, key) => `[#${createHash2("sha256").update(key).digest("hex").slice(0, 8)}]`);
@@ -12104,18 +12297,18 @@ function groupedGaps(items) {
12104
12297
  function renderSecurityMarkdown(report) {
12105
12298
  const drift = report.drift?.items ?? [];
12106
12299
  if (report.resourcesEvaluated === 0 && report.uncoveredTypes.length === 0 && drift.length === 0) return null;
12107
- const lines = ["### \u{1F512} Security", ""];
12300
+ const lines2 = ["### \u{1F512} Security", ""];
12108
12301
  if (report.resourcesEvaluated === 0) {
12109
- lines.push("_No resource in this plan is covered by the security rules yet._");
12302
+ lines2.push("_No resource in this plan is covered by the security rules yet._");
12110
12303
  } else if (report.findings.length === 0) {
12111
- lines.push(
12304
+ lines2.push(
12112
12305
  `**No findings** on the ${report.resourcesEvaluated} resources checked` + (report.notEvaluable.length > 0 ? `, but ${groupedGaps(report.notEvaluable).length} checks could not be decided before apply (below).` : ".")
12113
12306
  );
12114
12307
  } else {
12115
- lines.push(`**Findings: ${counts(report)}** on ${report.resourcesEvaluated} resources checked.`);
12308
+ lines2.push(`**Findings: ${counts(report)}** on ${report.resourcesEvaluated} resources checked.`);
12116
12309
  }
12117
12310
  if (report.findings.length > 0) {
12118
- lines.push("", "| Severity | Rule | Resource | Change | Detail |", "|---|---|---|---|---|");
12311
+ lines2.push("", "| Severity | Rule | Resource | Change | Detail |", "|---|---|---|---|---|");
12119
12312
  const rows = /* @__PURE__ */ new Map();
12120
12313
  for (const finding of report.findings) {
12121
12314
  const key = `${finding.ruleId}#${finding.address}`;
@@ -12125,35 +12318,35 @@ function renderSecurityMarkdown(report) {
12125
12318
  }
12126
12319
  for (const { finding, count } of rows.values()) {
12127
12320
  const detail = [evidenceSummary(finding), count > 1 ? `\xD7${count}` : ""].filter(Boolean).join(" ");
12128
- lines.push(
12321
+ lines2.push(
12129
12322
  `| ${BADGE[finding.severity]} | ${cell(ruleTitle(finding.ruleId))} | ${code(finding.address)} | ${ACTION[finding.action] ?? finding.action} | ${cell(detail) || "\u2014"} |`
12130
12323
  );
12131
12324
  }
12132
12325
  }
12133
12326
  if (report.combos.length > 0) {
12134
- lines.push("", "**Toxic combinations** \u2014 findings that are worse together, fix these first:", "");
12135
- lines.push("| Severity | Combination | Resource |", "|---|---|---|");
12327
+ lines2.push("", "**Toxic combinations** \u2014 findings that are worse together, fix these first:", "");
12328
+ lines2.push("| Severity | Combination | Resource |", "|---|---|---|");
12136
12329
  for (const combo of report.combos) {
12137
- lines.push(`| ${BADGE[combo.severity]} | ${cell(comboTitle(combo.comboId))} | ${code(combo.address)} |`);
12330
+ lines2.push(`| ${BADGE[combo.severity]} | ${cell(comboTitle(combo.comboId))} | ${code(combo.address)} |`);
12138
12331
  }
12139
12332
  }
12140
12333
  if (drift.length > 0) {
12141
12334
  const when = report.drift?.scanAt ? ` of ${report.drift.scanAt.slice(0, 10)}` : "";
12142
- lines.push("", `**Last scan vs. this plan** (AAA Sentinel scan${when}):`, "");
12143
- lines.push("| Severity | Rule | Resource | What it means |", "|---|---|---|---|");
12335
+ lines2.push("", `**Last scan vs. this plan** (AAA Sentinel scan${when}):`, "");
12336
+ lines2.push("| Severity | Rule | Resource | What it means |", "|---|---|---|---|");
12144
12337
  for (const item of drift) {
12145
- lines.push(`| ${BADGE[item.severity]} | ${cell(ruleTitle(item.ruleId))} | ${code(item.address)} | ${DRIFT_TEXT[item.status]} |`);
12338
+ lines2.push(`| ${BADGE[item.severity]} | ${cell(ruleTitle(item.ruleId))} | ${code(item.address)} | ${DRIFT_TEXT[item.status]} |`);
12146
12339
  }
12147
12340
  }
12148
12341
  if (report.drift?.truncated) {
12149
- lines.push("", "_The live scan returned only its 500 most severe findings: drift on lower ones may be missing._");
12342
+ lines2.push("", "_The live scan returned only its 500 most severe findings: drift on lower ones may be missing._");
12150
12343
  }
12151
12344
  const gaps = groupedGaps(report.notEvaluable);
12152
12345
  if (gaps.length > 0) {
12153
- lines.push("", `<details><summary>Not evaluable before apply (${gaps.length})</summary>`, "");
12154
- lines.push("| Rule | Resource | Why |", "|---|---|---|");
12155
- for (const gap of gaps) lines.push(`| ${cell(ruleTitle(gap.ruleId))} | ${code(gap.address)} | ${GAP_TEXT[gap.reason]} |`);
12156
- lines.push("", "</details>");
12346
+ lines2.push("", `<details><summary>Not evaluable before apply (${gaps.length})</summary>`, "");
12347
+ lines2.push("| Rule | Resource | Why |", "|---|---|---|");
12348
+ for (const gap of gaps) lines2.push(`| ${cell(ruleTitle(gap.ruleId))} | ${code(gap.address)} | ${GAP_TEXT[gap.reason]} |`);
12349
+ lines2.push("", "</details>");
12157
12350
  }
12158
12351
  const notes = [];
12159
12352
  if (report.accountRules.length > 0) {
@@ -12163,31 +12356,31 @@ function renderSecurityMarkdown(report) {
12163
12356
  notes.push(`Present in this plan but not checked before deploy: ${report.uncoveredTypes.map(code).join(", ")}.`);
12164
12357
  }
12165
12358
  notes.push("Evaluated on your machine with the same rules as the AAA Sentinel posture scan; the plan is not sent anywhere for this.");
12166
- lines.push("", ...notes.map((note) => `> ${note}`));
12167
- return lines.join("\n");
12359
+ lines2.push("", ...notes.map((note) => `> ${note}`));
12360
+ return lines2.join("\n");
12168
12361
  }
12169
12362
  function renderSecurityText(report) {
12170
- const lines = ["Security"];
12171
- if (report.resourcesEvaluated === 0) lines.push(" No resource in this plan is covered by the security rules yet.");
12172
- else lines.push(` ${report.resourcesEvaluated} resources checked: ${counts(report)}`);
12363
+ const lines2 = ["Security"];
12364
+ if (report.resourcesEvaluated === 0) lines2.push(" No resource in this plan is covered by the security rules yet.");
12365
+ else lines2.push(` ${report.resourcesEvaluated} resources checked: ${counts(report)}`);
12173
12366
  for (const finding of report.findings) {
12174
12367
  const detail = evidenceSummary(finding);
12175
- lines.push(` [${finding.severity.toUpperCase()}] ${ruleTitle(finding.ruleId)} - ${displayAddress(finding.address)} (${ACTION[finding.action] ?? finding.action})${detail ? ` - ${detail.replace(/`/g, "")}` : ""}`);
12368
+ lines2.push(` [${finding.severity.toUpperCase()}] ${ruleTitle(finding.ruleId)} - ${displayAddress(finding.address)} (${ACTION[finding.action] ?? finding.action})${detail ? ` - ${detail.replace(/`/g, "")}` : ""}`);
12176
12369
  }
12177
12370
  for (const combo of report.combos) {
12178
- lines.push(` [${combo.severity.toUpperCase()}] combination: ${comboTitle(combo.comboId)} - ${displayAddress(combo.address)}`);
12371
+ lines2.push(` [${combo.severity.toUpperCase()}] combination: ${comboTitle(combo.comboId)} - ${displayAddress(combo.address)}`);
12179
12372
  }
12180
12373
  for (const item of report.drift?.items ?? []) {
12181
- lines.push(` [LAST SCAN] ${ruleTitle(item.ruleId)} - ${displayAddress(item.address)}: ${DRIFT_TEXT[item.status]}`);
12374
+ lines2.push(` [LAST SCAN] ${ruleTitle(item.ruleId)} - ${displayAddress(item.address)}: ${DRIFT_TEXT[item.status]}`);
12182
12375
  }
12183
12376
  const gaps = groupedGaps(report.notEvaluable);
12184
12377
  if (gaps.length > 0) {
12185
- lines.push(` Not evaluable before apply (${gaps.length}):`);
12186
- for (const gap of gaps) lines.push(` - ${ruleTitle(gap.ruleId)} - ${displayAddress(gap.address)}: ${GAP_TEXT[gap.reason]}`);
12378
+ lines2.push(` Not evaluable before apply (${gaps.length}):`);
12379
+ for (const gap of gaps) lines2.push(` - ${ruleTitle(gap.ruleId)} - ${displayAddress(gap.address)}: ${GAP_TEXT[gap.reason]}`);
12187
12380
  }
12188
- if (report.accountRules.length > 0) lines.push(` ${report.accountRules.length} account-wide rules are only checked by the live scan.`);
12189
- if (report.uncoveredTypes.length > 0) lines.push(` Not checked before deploy: ${report.uncoveredTypes.join(", ")}`);
12190
- return lines.join("\n");
12381
+ if (report.accountRules.length > 0) lines2.push(` ${report.accountRules.length} account-wide rules are only checked by the live scan.`);
12382
+ if (report.uncoveredTypes.length > 0) lines2.push(` Not checked before deploy: ${report.uncoveredTypes.join(", ")}`);
12383
+ return lines2.join("\n");
12191
12384
  }
12192
12385
 
12193
12386
  // src/run.ts
@@ -12230,10 +12423,10 @@ function decodePlan(bytes) {
12230
12423
  var fmtUsd2 = (n) => `${n < 0 ? "-" : "+"}$${Math.abs(n).toFixed(2)}/mo`;
12231
12424
  function costText(estimate) {
12232
12425
  const { coverage } = estimate;
12233
- const total = estimate.totalMonthlyDeltaUsd === null ? "no total (coverage too low)" : `${fmtUsd2(estimate.totalMonthlyDeltaUsd)} (list price, USD)`;
12426
+ const total2 = estimate.totalMonthlyDeltaUsd === null ? "no total (coverage too low)" : `${fmtUsd2(estimate.totalMonthlyDeltaUsd)} (list price, USD)`;
12234
12427
  return [
12235
12428
  "Cost",
12236
- ` Estimated change: ${total}`,
12429
+ ` Estimated change: ${total2}`,
12237
12430
  ` Coverage: ${coverage.pricedResources} of ${coverage.pricedResources + coverage.unpricedResources} billable resources priced`
12238
12431
  ].join("\n");
12239
12432
  }
@@ -12331,8 +12524,15 @@ async function fix(argv) {
12331
12524
  if (outcome.stdout) console.log(outcome.stdout);
12332
12525
  process.exitCode = outcome.exitCode;
12333
12526
  }
12527
+ async function posture(argv) {
12528
+ const outcome = await runPostureCli(argv, { env: process.env });
12529
+ for (const warning of outcome.warnings) console.warn(`iac-cost: ${warning}`);
12530
+ if (outcome.stdout) console.log(outcome.stdout);
12531
+ process.exitCode = outcome.exitCode;
12532
+ }
12334
12533
  async function main() {
12335
12534
  if (process.argv[2] === "fix") return fix(process.argv.slice(3));
12535
+ if (process.argv[2] === "posture") return posture(process.argv.slice(3));
12336
12536
  const options = parseCliArgs(process.argv.slice(2), process.env);
12337
12537
  if ("error" in options) {
12338
12538
  console.error(`iac-cost: ${options.error}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aaa-sentinel/iac-cost",
3
- "version": "0.3.1",
4
- "description": "Estimate the monthly cost delta of a Terraform plan, check it against the AAA Sentinel security rules and open security fixes as pull requests from your CI",
3
+ "version": "0.4.0",
4
+ "description": "Estimate the monthly cost delta of a Terraform plan, check it against the AAA Sentinel security rules, open security fixes as pull requests and report how the security posture changed, from your CI",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {