@aaa-sentinel/iac-cost 0.3.1 → 0.4.1

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 +64 -5
  2. package/dist/cli.js +682 -116
  3. package/package.json +2 -2
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()) {
@@ -8152,6 +8153,8 @@ function sqlInstances(index) {
8152
8153
  const builder = new ObservationBuilder(index, sql, "cloud_sql_instance", "gcp").cloudId("name");
8153
8154
  const ip = ["settings", 0, "ip_configuration", 0];
8154
8155
  builder.from("ipv4Enabled", index.get(sql, ...ip, "ipv4_enabled"), Boolean, true);
8156
+ builder.from("sslMode", index.get(sql, ...ip, "ssl_mode"), String, null);
8157
+ builder.from("requireSsl", index.get(sql, ...ip, "require_ssl"), Boolean, null);
8155
8158
  const networks = index.get(sql, ...ip, "authorized_networks");
8156
8159
  if (networks.state === "unknown") builder.gap("authorizedNetworks", "known_after_apply");
8157
8160
  else {
@@ -8267,6 +8270,18 @@ var RULE_TITLES = {
8267
8270
  SEC_AWS_NETWORK_ADMIN_PREFIX_LIST: "Admin ports open to the internet through a prefix list",
8268
8271
  SEC_AWS_EXPOSURE_S3_PUBLICO: "Public S3 bucket",
8269
8272
  SEC_AWS_EXPOSURE_LAMBDA_PUBLICA: "Lambda invocable from the internet",
8273
+ SEC_AZURE_ENCRYPTION_KEYVAULT_SIN_PURGA: "Key Vault without purge protection",
8274
+ SEC_AZURE_ENCRYPTION_SQL_SIN_TDE: "SQL database without TDE encryption",
8275
+ SEC_AZURE_LOGGING_VNET_SIN_FLOW_LOGS: "VNet without flow logs",
8276
+ SEC_GCP_ENCRYPTION_KMS_SIN_ROTACION: "Cloud KMS key without 90-day rotation",
8277
+ SEC_GCP_LOGGING_VPC_SIN_FLOW_LOGS: "VPC network with subnets without flow logs",
8278
+ SEC_AWS_NETWORK_BD_ABIERTO: "Database ports open to the internet",
8279
+ SEC_AZURE_NETWORK_BD_ABIERTO: "NSG with database ports open to the internet",
8280
+ SEC_GCP_NETWORK_BD_ABIERTO: "Firewall with database ports open to the internet",
8281
+ SEC_AWS_EXPOSURE_RDS_PUBLICA: "Publicly accessible RDS instance",
8282
+ SEC_GCP_ENCRYPTION_SQL_SIN_SSL: "Cloud SQL accepts unencrypted connections",
8283
+ SEC_AWS_IAM_CLAVE_ANTIGUA: "Active access key older than 90 days",
8284
+ SEC_AWS_IAM_POLITICA_CONTRASENAS: "Weak or missing IAM password policy",
8270
8285
  SEC_AWS_ENCRYPTION_KMS_SIN_ROTACION: "KMS key without rotation",
8271
8286
  SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR: "Unencrypted RDS",
8272
8287
  SEC_AWS_LOGGING_TRAIL_PARADO: "CloudTrail stopped",
@@ -8285,6 +8300,7 @@ var RULE_TITLES = {
8285
8300
  SEC_AZURE_LOGGING_SIN_DIAGNOSTICO: "Subscription does not export Activity Log",
8286
8301
  SEC_GCP_NETWORK_ADMIN_ABIERTO: "Firewall with admin ports open to the internet",
8287
8302
  SEC_GCP_EXPOSURE_SQL_PUBLICO: "Cloud SQL authorized from 0.0.0.0/0",
8303
+ SEC_GCP_EXPOSURE_GCS_PUBLICO: "Public Cloud Storage bucket (allUsers or allAuthenticatedUsers)",
8288
8304
  SEC_GCP_ENCRYPTION_SQL_SIN_CMEK: "Cloud SQL without CMEK",
8289
8305
  SEC_GCP_SECRETS_FUNCTION_ENV: "Secret in a Cloud Function environment variable",
8290
8306
  SEC_GCP_IAM_ROL_PRIMITIVO: "Primitive Owner or Editor role on the project",
@@ -8779,7 +8795,7 @@ function carry(findingId, fix2, state) {
8779
8795
  }
8780
8796
  async function runFix(options, deps) {
8781
8797
  const warnings = [];
8782
- const lines = [];
8798
+ const lines2 = [];
8783
8799
  const reports = [];
8784
8800
  const { host } = deps;
8785
8801
  let input;
@@ -8787,7 +8803,7 @@ async function runFix(options, deps) {
8787
8803
  input = await deps.api.load();
8788
8804
  } catch (error2) {
8789
8805
  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 };
8806
+ return { stdout: render(options, lines2, deps.accountId, null), warnings, lines: lines2, reported: 0 };
8791
8807
  }
8792
8808
  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
8809
  let publicRepo = false;
@@ -8818,7 +8834,7 @@ async function runFix(options, deps) {
8818
8834
  }
8819
8835
  if (state !== "merged") {
8820
8836
  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 });
8837
+ lines2.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: existing.address, outcome: state, pullRequest: existing.pullRequest.url });
8822
8838
  continue;
8823
8839
  }
8824
8840
  existing.state = "merged";
@@ -8840,7 +8856,7 @@ async function runFix(options, deps) {
8840
8856
  const merged = existing && sameRepo && existing.state === "merged" && existing.pullRequest ? existing : null;
8841
8857
  if (merged && outcome.kind !== "patch") {
8842
8858
  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 } : {} });
8859
+ lines2.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: merged.address, outcome: "merged", ...merged.pullRequest ? { pullRequest: merged.pullRequest.url } : {} });
8844
8860
  continue;
8845
8861
  }
8846
8862
  const base = { findingId: finding.findingId, ruleId: finding.ruleId, address: outcome.address };
@@ -8853,18 +8869,18 @@ async function runFix(options, deps) {
8853
8869
  ...outcome.file !== void 0 ? { file: outcome.file } : {},
8854
8870
  ...outcome.attribute !== void 0 ? { attribute: outcome.attribute } : {}
8855
8871
  });
8856
- lines.push({ ...base, outcome: "suggested", reason: outcome.reason, ...outcome.file !== void 0 ? { file: outcome.file } : {} });
8872
+ lines2.push({ ...base, outcome: "suggested", reason: outcome.reason, ...outcome.file !== void 0 ? { file: outcome.file } : {} });
8857
8873
  continue;
8858
8874
  }
8859
8875
  if (outcome.kind === "fixed_in_code") {
8860
8876
  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 });
8877
+ lines2.push({ ...base, outcome: "pending", reason: "fixed_in_code", file: outcome.file });
8862
8878
  continue;
8863
8879
  }
8864
8880
  const patch = outcome;
8865
8881
  const pending = (reason) => {
8866
8882
  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 });
8883
+ lines2.push({ ...base, outcome: "pending", reason, file: patch.file, changes: patch.changes });
8868
8884
  };
8869
8885
  if (options.dryRun || !host) {
8870
8886
  pending("dry_run");
@@ -8872,7 +8888,7 @@ async function runFix(options, deps) {
8872
8888
  }
8873
8889
  if (host.unsupported) {
8874
8890
  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 });
8891
+ lines2.push({ ...base, outcome: "suggested", reason: "unsupported_host", file: patch.file, changes: patch.changes });
8876
8892
  continue;
8877
8893
  }
8878
8894
  if (publicRepo) {
@@ -8896,7 +8912,7 @@ async function runFix(options, deps) {
8896
8912
  repository: host.repository,
8897
8913
  pullRequest: { url: pr.url, number: pr.number }
8898
8914
  });
8899
- lines.push({ ...base, outcome: state, file: patch.file, pullRequest: pr.url, changes: patch.changes });
8915
+ lines2.push({ ...base, outcome: state, file: patch.file, pullRequest: pr.url, changes: patch.changes });
8900
8916
  };
8901
8917
  try {
8902
8918
  if (await host.branchExists(branch)) {
@@ -8946,7 +8962,7 @@ async function runFix(options, deps) {
8946
8962
  warnings.push(`could not record the fixes in AAA Sentinel: ${error2.message}`);
8947
8963
  }
8948
8964
  }
8949
- return { stdout: render(options, lines, deps.accountId, input.scanAt), warnings, lines, reported };
8965
+ return { stdout: render(options, lines2, deps.accountId, input.scanAt), warnings, lines: lines2, reported };
8950
8966
  }
8951
8967
  var SkipFinding = class extends Error {
8952
8968
  };
@@ -8965,11 +8981,11 @@ var OUTCOME_TEXT = {
8965
8981
  pending: "pending",
8966
8982
  suggested: "change suggested, no pull request"
8967
8983
  };
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.`;
8984
+ function render(options, lines2, accountId, scanAt) {
8985
+ if (options.format === "json") return JSON.stringify({ formatVersion: "1", accountId, scanAt, dryRun: options.dryRun, fixes: lines2 }, null, 2);
8986
+ if (lines2.length === 0) return `No open finding of account ${accountId} matches a resource of this plan.`;
8971
8987
  const out = [`Security fixes for account ${accountId}${options.dryRun ? " (dry run: nothing opened or recorded)" : ""}`];
8972
- for (const line of lines) {
8988
+ for (const line of lines2) {
8973
8989
  const detail = [line.reason, line.file, line.pullRequest].filter(Boolean).join(", ");
8974
8990
  out.push(` ${line.address} \u2014 ${title(line.ruleId)}: ${OUTCOME_TEXT[line.outcome]}${detail ? ` (${detail})` : ""}`);
8975
8991
  for (const change of line.changes ?? []) out.push(` ${change}`);
@@ -9078,6 +9094,168 @@ ${FIX_USAGE}`);
9078
9094
  return { stdout: result.stdout, warnings: [...warnings, ...result.warnings], exitCode: 0 };
9079
9095
  }
9080
9096
 
9097
+ // src/posture/posture.ts
9098
+ import { parseArgs as parseArgs3 } from "node:util";
9099
+ var POSTURE_USAGE = `Usage: iac-cost posture --account <accountId> [options]
9100
+
9101
+ Prints how the security posture of an AAA Sentinel account changed over a
9102
+ period: Score from the first to the last day with a scan, distinct findings
9103
+ resolved and new, mean time to fix, and catalog or coverage changes that make
9104
+ the Score not comparable.
9105
+
9106
+ Options:
9107
+ --account <accountId> AAA Sentinel account id (acc_\u2026), or IAC_SECURITY_ACCOUNT_ID
9108
+ --days <30|90|365> Period (default: 90)
9109
+ --format <markdown|text|json>
9110
+ Output format (default: markdown)
9111
+ --fail-on-new <critical|high|medium|low>
9112
+ Exit 1 when new findings at this severity or worse
9113
+ appeared in the period (findings that cannot be
9114
+ compared with the previous scan do not count).
9115
+ With the gate, a 4xx from the API (scope, account)
9116
+ exits 2; our outage still exits 0.
9117
+ -h, --help Show this help
9118
+
9119
+ Environment:
9120
+ IAC_SECURITY_CLIENT_ID / IAC_SECURITY_CLIENT_SECRET OAuth2 app with the security:read scope`;
9121
+ var SEVERITIES2 = ["critical", "high", "medium", "low"];
9122
+ var DAYS = [30, 90, 365];
9123
+ var FORMATS2 = ["markdown", "text", "json"];
9124
+ var TIMEOUT_MS3 = 35e3;
9125
+ var APP_URL = "https://app.aaa-sentinel.com";
9126
+ var total = (c) => SEVERITIES2.reduce((n, s) => n + (c[s] ?? 0), 0);
9127
+ var bySeverity = (c) => SEVERITIES2.filter((s) => (c[s] ?? 0) > 0).map((s) => `${c[s]} ${s}`).join(", ");
9128
+ var REASONS = {
9129
+ catalog_changed: "the rule catalog changed",
9130
+ coverage_changed: "what could be evaluated changed",
9131
+ not_compared: "a scan could not be compared with the previous one",
9132
+ scored_changed: "the number of scoring rules changed",
9133
+ partial: "one end was read incompletely"
9134
+ };
9135
+ function scoreLine(summary) {
9136
+ const from = summary.from?.score;
9137
+ const to = summary.to?.score;
9138
+ if (from === null || from === void 0 || to === null || to === void 0) return "no comparable Score in the period";
9139
+ if (!summary.comparable) {
9140
+ const why = summary.notComparableReasons.map((r) => REASONS[r] ?? r).join("; ");
9141
+ return `${from} \u2192 ${to}, not comparable (${why})`;
9142
+ }
9143
+ if (to > from) return `up from ${from} to ${to}`;
9144
+ if (to < from) return `down from ${from} to ${to}`;
9145
+ return `steady at ${to}`;
9146
+ }
9147
+ function lines(history) {
9148
+ const s = history.summary;
9149
+ const fix2 = ["critical", "high"].filter((sev) => s.meanFixDays[sev] !== null).map((sev) => `${sev} ${s.meanFixDays[sev]} d`);
9150
+ const out = [
9151
+ ["Score", scoreLine(s)],
9152
+ ["Resolved", `${total(s.resolved)}${total(s.resolved) ? ` (${bySeverity(s.resolved)})` : ""}`],
9153
+ ["New", `${total(s.opened)}${total(s.opened) ? ` (${bySeverity(s.opened)})` : ""}`]
9154
+ ];
9155
+ if (total(s.surfaced)) out.push(["Not comparable", `${total(s.surfaced)} new findings whose rule was not fully evaluated before`]);
9156
+ if (total(s.unverified)) out.push(["Unverified", `${total(s.unverified)} gone without confirmation`]);
9157
+ out.push(["Mean time to fix", fix2.length ? fix2.join(", ") : "no critical or high finding fixed with a known date"]);
9158
+ out.push(["Scans", String(s.scans)]);
9159
+ return out;
9160
+ }
9161
+ function renderPosture(history, format) {
9162
+ const enlace = `${APP_URL}/posture?accountId=${encodeURIComponent(history.accountId)}`;
9163
+ const notas = [];
9164
+ if (history.points.length < 2) notas.push("The trend appears once the period has scans on at least two different days.");
9165
+ if (history.acceptedRisks) notas.push("The account has accepted risks: each day keeps the Score computed at the time.");
9166
+ if (history.summary.notCompared) notas.push(`${history.summary.notCompared} scan(s) could not be compared with the previous one.`);
9167
+ if (history.summary.changesTruncated) notas.push("A scan had more changes than are stored: the figures are a minimum.");
9168
+ if (history.truncated) notas.push("The period has more scans than fit: only the most recent ones are counted.");
9169
+ const titulo = `Security posture, last ${history.days} days`;
9170
+ if (format === "text") {
9171
+ return [titulo, ...lines(history).map(([k, v]) => ` ${k}: ${v}`), ...notas.map((n) => ` ${n}`), ` ${enlace}`].join("\n");
9172
+ }
9173
+ return [
9174
+ `### ${titulo}`,
9175
+ "",
9176
+ "| | |",
9177
+ "|---|---|",
9178
+ ...lines(history).map(([k, v]) => `| ${k} | ${v} |`),
9179
+ "",
9180
+ ...notas.map((n) => `> ${n}`),
9181
+ ...notas.length ? [""] : [],
9182
+ `[Open in AAA Sentinel](${enlace})`
9183
+ ].join("\n");
9184
+ }
9185
+ function newAtOrAbove(summary, severity) {
9186
+ return SEVERITIES2.slice(0, SEVERITIES2.indexOf(severity) + 1).reduce((n, s) => n + (summary.opened[s] ?? 0), 0);
9187
+ }
9188
+ async function fetchPostureHistory(config2) {
9189
+ const fetchImpl = config2.fetchImpl ?? fetch;
9190
+ const token = await fetchToken(config2, "security:read");
9191
+ const url = `${config2.baseUrl}/v1/security/posture-history?accountId=${encodeURIComponent(config2.accountId)}&days=${config2.days}`;
9192
+ const response = await fetchImpl(url, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(TIMEOUT_MS3) });
9193
+ if (!response.ok) throw new Error(`posture_history_http_${response.status}`);
9194
+ return await response.json();
9195
+ }
9196
+ async function runPostureCli(argv, io) {
9197
+ const warnings = [];
9198
+ const fail = (message) => ({ stdout: null, warnings: [...warnings, message], exitCode: 2 });
9199
+ const { values, positionals } = parseArgs3({
9200
+ args: [...argv],
9201
+ strict: false,
9202
+ allowPositionals: true,
9203
+ options: {
9204
+ account: { type: "string" },
9205
+ days: { type: "string" },
9206
+ format: { type: "string" },
9207
+ "fail-on-new": { type: "string" },
9208
+ help: { type: "boolean", short: "h" }
9209
+ }
9210
+ });
9211
+ const known = /* @__PURE__ */ new Set(["account", "days", "format", "fail-on-new", "help", "h"]);
9212
+ for (const name of Object.keys(values)) {
9213
+ if (known.has(name)) continue;
9214
+ if (/^fail[-_]?on/i.test(name)) return fail(`unknown option --${name}: the gate is spelled --fail-on-new`);
9215
+ warnings.push(`unknown option --${name} ignored`);
9216
+ }
9217
+ if (values.help === true || values.h === true) return { stdout: POSTURE_USAGE, warnings, exitCode: 0 };
9218
+ for (const name of ["account", "days", "format", "fail-on-new"]) {
9219
+ const v = values[name];
9220
+ if (v === true || typeof v === "string" && v.startsWith("-")) return fail(`--${name} needs a value`);
9221
+ }
9222
+ if (positionals.length > 0) return fail(`unexpected argument ${positionals.join(" ")}`);
9223
+ const account = (typeof values.account === "string" ? values.account : io.env["IAC_SECURITY_ACCOUNT_ID"])?.trim();
9224
+ if (!account) return fail(`posture needs --account <accountId>
9225
+
9226
+ ${POSTURE_USAGE}`);
9227
+ const days = values.days === void 0 ? 90 : Number(values.days);
9228
+ if (!DAYS.includes(days)) return fail("--days must be 30, 90 or 365");
9229
+ const format = values.format === void 0 ? "markdown" : values.format;
9230
+ if (typeof format !== "string" || !FORMATS2.includes(format)) return fail(`--format must be one of ${FORMATS2.join(", ")}`);
9231
+ let gate = null;
9232
+ if (values["fail-on-new"] !== void 0) {
9233
+ const raw = typeof values["fail-on-new"] === "string" ? values["fail-on-new"].trim().toLowerCase() : "";
9234
+ if (!SEVERITIES2.includes(raw)) return fail(`--fail-on-new must be one of ${SEVERITIES2.join(", ")}`);
9235
+ gate = raw;
9236
+ }
9237
+ const baseUrl = resolveBaseUrl(io.env["IAC_COST_BASE_URL"] ?? "https://api.aaa-sentinel.com");
9238
+ if (baseUrl === null) return fail("IAC_COST_BASE_URL must be an https URL");
9239
+ const clientId = io.env["IAC_SECURITY_CLIENT_ID"];
9240
+ const clientSecret = io.env["IAC_SECURITY_CLIENT_SECRET"];
9241
+ if (!clientId || !clientSecret) return fail("IAC_SECURITY_CLIENT_ID/IAC_SECURITY_CLIENT_SECRET are not set: create an app with the security:read scope");
9242
+ let history;
9243
+ try {
9244
+ history = await fetchPostureHistory({ baseUrl, clientId, clientSecret, accountId: account, days, ...io.fetchImpl ? { fetchImpl: io.fetchImpl } : {} });
9245
+ } catch (error2) {
9246
+ const mensaje = error2.message;
9247
+ 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`);
9248
+ return { stdout: null, warnings: [...warnings, `could not read the posture history, skipping: ${mensaje}`], exitCode: 0 };
9249
+ }
9250
+ if (history.access !== "full") {
9251
+ return { stdout: null, warnings: [...warnings, "the account does not have the security add-on: no posture history to show"], exitCode: 0 };
9252
+ }
9253
+ const nuevos = gate ? newAtOrAbove(history.summary, gate) : 0;
9254
+ const stdout = format === "json" ? JSON.stringify(history, null, 2) : renderPosture(history, format);
9255
+ if (gate && nuevos > 0) warnings.push(`${nuevos} new finding(s) at ${gate} or worse in the last ${days} days (--fail-on-new ${gate})`);
9256
+ return { stdout, warnings, exitCode: gate && nuevos > 0 ? 1 : 0 };
9257
+ }
9258
+
9081
9259
  // src/terraform-plan.ts
9082
9260
  function actionOf2(actions) {
9083
9261
  if (actions.includes("create") && actions.includes("delete")) return "replace";
@@ -9169,51 +9347,51 @@ function actionVerb(action) {
9169
9347
  }
9170
9348
  function renderComment(estimate) {
9171
9349
  const { coverage } = estimate;
9172
- const lines = [COMMENT_MARKER, "### \u{1F4B2} Infrastructure cost estimate", ""];
9173
- lines.push(
9350
+ const lines2 = [COMMENT_MARKER, "### \u{1F4B2} Infrastructure cost estimate", ""];
9351
+ lines2.push(
9174
9352
  `**Coverage: ${coverage.pricedResources} of ${coverage.pricedResources + coverage.unpricedResources} billable resources priced** (${coverage.freeResources} more have no cost by design).`
9175
9353
  );
9176
- lines.push("");
9354
+ lines2.push("");
9177
9355
  const display = estimate.displayCurrency?.approximate ? estimate.displayCurrency : null;
9178
9356
  const delta = (usd, converted) => display && typeof converted === "number" ? fmtConverted(converted, display.code) : fmtUsd(usd);
9179
9357
  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)`
9358
+ const total2 = delta(estimate.totalMonthlyDeltaUsd, estimate.totalMonthlyDelta);
9359
+ lines2.push(
9360
+ display ? `**Estimated change: ${total2}** (list price, ${fmtUsd(estimate.totalMonthlyDeltaUsd)} in USD)` : `**Estimated change: ${total2}** (list price, USD)`
9183
9361
  );
9184
9362
  } else {
9185
- lines.push(
9363
+ lines2.push(
9186
9364
  `_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
9365
  );
9188
9366
  }
9189
- lines.push("");
9190
- lines.push(
9367
+ lines2.push("");
9368
+ lines2.push(
9191
9369
  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
9370
  );
9193
9371
  if (estimate.priced.length > 0) {
9194
- lines.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
9372
+ lines2.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
9195
9373
  for (const item of estimate.priced) {
9196
- lines.push(
9374
+ lines2.push(
9197
9375
  `| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${delta(item.monthlyDeltaUsd, item.monthlyDelta)} |`
9198
9376
  );
9199
9377
  }
9200
9378
  }
9201
9379
  if (estimate.unpriced.length > 0) {
9202
- lines.push("", "<details><summary>Not priced (" + coverage.unpricedResources + " resources)</summary>", "");
9203
- lines.push("| Resource | Change | Qty | Why |", "|---|---|---|---|");
9380
+ lines2.push("", "<details><summary>Not priced (" + coverage.unpricedResources + " resources)</summary>", "");
9381
+ lines2.push("| Resource | Change | Qty | Why |", "|---|---|---|---|");
9204
9382
  for (const item of estimate.unpriced) {
9205
- lines.push(`| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${reasonText(item.reason)} |`);
9383
+ lines2.push(`| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${reasonText(item.reason)} |`);
9206
9384
  }
9207
- lines.push("", "</details>");
9385
+ lines2.push("", "</details>");
9208
9386
  }
9209
9387
  const truncated = estimate.catalogs.filter((c) => c.truncated);
9210
9388
  if (truncated.length > 0) {
9211
- lines.push(
9389
+ lines2.push(
9212
9390
  "",
9213
9391
  `\u26A0\uFE0F The price catalog for ${truncated.map((c) => c.region).join(", ")} was truncated by pagination: some prices may be missing.`
9214
9392
  );
9215
9393
  }
9216
- return lines.join("\n");
9394
+ return lines2.join("\n");
9217
9395
  }
9218
9396
 
9219
9397
  // src/security/drift.ts
@@ -9376,8 +9554,6 @@ function publicVulnerable(input, provider) {
9376
9554
  }
9377
9555
 
9378
9556
  // ../../services/security/src/rules/aws/combos.ts
9379
- var DIA_MS = 24 * 60 * 60 * 1e3;
9380
- var OLD_ACCESS_KEY_DAYS = 90;
9381
9557
  function lambdaRef(resourceId, region) {
9382
9558
  const partes = resourceId.split(":");
9383
9559
  if (partes[0] === "arn" && partes[2] === "lambda" && partes[5] === "function" && partes[3] && partes[6]) {
@@ -9416,38 +9592,23 @@ var AWS_SECURITY_COMBOS = [
9416
9592
  id: "COMBO_AWS_PUBLIC_DB_UNENCRYPTED",
9417
9593
  provider: "aws",
9418
9594
  severity: "critical",
9419
- rules: ["SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR"],
9420
- detect: (input) => input.findings.filter((f) => f.ruleId === "SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR").flatMap((f) => {
9421
- const db = observationOf(input, "rds_instance", f.region, f.resourceId);
9422
- if (db?.attributes["publiclyAccessible"] !== true) return [];
9423
- return [{ region: f.region, resourceId: f.resourceId, resourceName: f.resourceName, findingIds: [f.id], evidence: { publiclyAccessible: true } }];
9424
- })
9595
+ rules: ["SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR", "SEC_AWS_EXPOSURE_RDS_PUBLICA"],
9596
+ detect: (input) => sameResource(input, "SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR", "SEC_AWS_EXPOSURE_RDS_PUBLICA").map((m) => ({ ...m, evidence: { publiclyAccessible: true } }))
9425
9597
  },
9426
9598
  {
9427
9599
  // Entra a la consola solo con contraseña y además tiene una clave de acceso vieja y activa: dos puertas sin segundo factor.
9428
9600
  id: "COMBO_AWS_USER_NO_MFA_OLD_KEY",
9429
9601
  provider: "aws",
9430
9602
  severity: "critical",
9431
- rules: ["SEC_AWS_IAM_USUARIO_SIN_MFA"],
9432
- detect: (input) => input.findings.filter((f) => f.ruleId === "SEC_AWS_IAM_USUARIO_SIN_MFA").flatMap((f) => {
9433
- const user = observationOf(input, "iam_user", f.region, f.resourceId);
9434
- const claves = Array.isArray(user?.attributes["accessKeys"]) ? user.attributes["accessKeys"] : [];
9435
- const viejas = claves.filter((k) => k["active"] === true && typeof k["createdAt"] === "string").map((k) => k["createdAt"]).filter((creada) => {
9436
- const dias = Math.floor((input.now.getTime() - Date.parse(creada)) / DIA_MS);
9437
- return Number.isFinite(dias) && dias > OLD_ACCESS_KEY_DAYS;
9438
- }).sort();
9439
- if (viejas.length === 0) return [];
9440
- return [
9441
- {
9442
- region: f.region,
9443
- resourceId: f.resourceId,
9444
- resourceName: f.resourceName,
9445
- findingIds: [f.id],
9446
- // La fecha y no la edad: la edad la calcula quien la enseña, si no se quedaría congelada el día del escaneo.
9447
- evidence: { oldActiveKeys: viejas.length, oldestKeyCreatedAt: viejas[0] }
9448
- }
9449
- ];
9450
- })
9603
+ rules: ["SEC_AWS_IAM_USUARIO_SIN_MFA", "SEC_AWS_IAM_CLAVE_ANTIGUA"],
9604
+ detect: (input) => {
9605
+ const viejas = findingsByResource(input.findings, "SEC_AWS_IAM_CLAVE_ANTIGUA");
9606
+ return sameResource(input, "SEC_AWS_IAM_USUARIO_SIN_MFA", "SEC_AWS_IAM_CLAVE_ANTIGUA").map((m) => {
9607
+ const claves = viejas.get(`${m.region}::${m.resourceId}`)?.evidence["oldActiveKeys"];
9608
+ const lista = Array.isArray(claves) ? claves : [];
9609
+ return { ...m, evidence: { oldActiveKeys: lista.length, oldestKeyCreatedAt: lista[0]?.["createdAt"] ?? null } };
9610
+ });
9611
+ }
9451
9612
  },
9452
9613
  {
9453
9614
  // Alcanzable desde internet y con un CVE crítico o alto que ya tiene exploit público: el ataque está escrito.
@@ -9926,6 +10087,31 @@ var rdsClusterSnapshotPublico = {
9926
10087
  ],
9927
10088
  evaluate: (observation) => snapshotPublicoEvaluate(observation, RDS_CHECKS.clusterSnapshotAttributes)
9928
10089
  };
10090
+ function rdsPublicaEvaluate(observation) {
10091
+ const publica = observation.attributes.publiclyAccessible;
10092
+ if (typeof publica !== "boolean") {
10093
+ return { status: "unknown", check: RDS_CHECKS.instances, attribute: "publiclyAccessible" };
10094
+ }
10095
+ if (!publica) return { status: "ok" };
10096
+ return {
10097
+ status: "finding",
10098
+ evidence: {
10099
+ publiclyAccessible: true,
10100
+ engine: observation.attributes.engine ?? null,
10101
+ securityGroupIds: observation.attributes.securityGroupIds ?? null
10102
+ }
10103
+ };
10104
+ }
10105
+ var rdsPublica = {
10106
+ id: "SEC_AWS_EXPOSURE_RDS_PUBLICA",
10107
+ scope: "resource",
10108
+ category: "security_exposure",
10109
+ severity: "high",
10110
+ provider: "aws",
10111
+ kinds: ["rds_instance"],
10112
+ requires: [REGION_CHECKS.listRegions, RDS_CHECKS.instances],
10113
+ evaluate: rdsPublicaEvaluate
10114
+ };
9929
10115
 
9930
10116
  // ../../services/security/src/rules/aws/iam.ts
9931
10117
  function falta4(check, attribute) {
@@ -9979,6 +10165,74 @@ var usuarioSinMfa = {
9979
10165
  requires: [IAM_CHECKS.listUsers, IAM_CHECKS.loginProfile, IAM_CHECKS.mfaDevices],
9980
10166
  evaluate: usuarioSinMfaEvaluate
9981
10167
  };
10168
+ var ACCESS_KEY_MAX_AGE_DAYS = 90;
10169
+ var DIA_MS = 864e5;
10170
+ function claveAntiguaEvaluate(observation, ahora = Date.now()) {
10171
+ const claves = observation.attributes.accessKeys;
10172
+ if (!Array.isArray(claves)) return falta4(IAM_CHECKS.accessKeys, "accessKeys");
10173
+ const viejas = [];
10174
+ let indecisa = false;
10175
+ for (const entrada of claves) {
10176
+ if (typeof entrada !== "object" || entrada === null) {
10177
+ indecisa = true;
10178
+ continue;
10179
+ }
10180
+ const clave = entrada;
10181
+ if (clave.active === false) continue;
10182
+ const creada = typeof clave.createdAt === "string" ? Date.parse(clave.createdAt) : Number.NaN;
10183
+ if (clave.active !== true || Number.isNaN(creada)) {
10184
+ indecisa = true;
10185
+ continue;
10186
+ }
10187
+ if (ahora - creada > ACCESS_KEY_MAX_AGE_DAYS * DIA_MS) viejas.push({ id: clave.id ?? null, createdAt: clave.createdAt });
10188
+ }
10189
+ if (viejas.length === 0) return indecisa ? falta4(IAM_CHECKS.accessKeys, "accessKeys") : { status: "ok" };
10190
+ viejas.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
10191
+ return { status: "finding", evidence: { oldActiveKeys: viejas, maxAgeDays: ACCESS_KEY_MAX_AGE_DAYS } };
10192
+ }
10193
+ var claveAntigua = {
10194
+ id: "SEC_AWS_IAM_CLAVE_ANTIGUA",
10195
+ scope: "resource",
10196
+ category: "security_iam",
10197
+ severity: "medium",
10198
+ provider: "aws",
10199
+ kinds: ["iam_user"],
10200
+ requires: [IAM_CHECKS.listUsers, IAM_CHECKS.accessKeys],
10201
+ evaluate: (observation) => claveAntiguaEvaluate(observation)
10202
+ };
10203
+ var PASSWORD_MIN_LENGTH = 14;
10204
+ var PASSWORD_REUSE_PREVENTION = 24;
10205
+ function politicaContrasenasEvaluate(observation) {
10206
+ const usuarios = observation.attributes.userCount;
10207
+ if (typeof usuarios !== "number") return falta4(IAM_CHECKS.accountSummary, "userCount");
10208
+ if (usuarios === 0) return { status: "ok" };
10209
+ if (!("passwordPolicy" in observation.attributes)) return falta4(IAM_CHECKS.passwordPolicy, "passwordPolicy");
10210
+ const politica = observation.attributes.passwordPolicy;
10211
+ if (politica === null) {
10212
+ return { status: "finding", evidence: { passwordPolicy: null, userCount: usuarios } };
10213
+ }
10214
+ if (typeof politica !== "object" || Array.isArray(politica)) return falta4(IAM_CHECKS.passwordPolicy, "passwordPolicy");
10215
+ const p = politica;
10216
+ const longitud = typeof p.minimumLength === "number" ? p.minimumLength : null;
10217
+ const reuso = typeof p.reusePrevention === "number" ? p.reusePrevention : null;
10218
+ const corta = longitud === null || longitud < PASSWORD_MIN_LENGTH;
10219
+ const reutilizable = reuso === null || reuso < PASSWORD_REUSE_PREVENTION;
10220
+ if (!corta && !reutilizable) return { status: "ok" };
10221
+ return {
10222
+ status: "finding",
10223
+ evidence: { minimumLength: longitud, reusePrevention: reuso, userCount: usuarios }
10224
+ };
10225
+ }
10226
+ var politicaContrasenas = {
10227
+ id: "SEC_AWS_IAM_POLITICA_CONTRASENAS",
10228
+ scope: "resource",
10229
+ category: "security_iam",
10230
+ severity: "low",
10231
+ provider: "aws",
10232
+ kinds: ["iam_account"],
10233
+ requires: [IAM_CHECKS.accountSummary, IAM_CHECKS.passwordPolicy],
10234
+ evaluate: politicaContrasenasEvaluate
10235
+ };
9982
10236
  function exclusion(observation) {
9983
10237
  const value = observation.attributes.excluded;
9984
10238
  if (value === null) return { known: true, excluded: false };
@@ -10397,6 +10651,7 @@ var trailGestionIncompleta = {
10397
10651
 
10398
10652
  // ../../services/security/src/collectors/internet.ts
10399
10653
  var ADMIN_PORTS2 = [22, 3389];
10654
+ var DATABASE_PORTS = [1433, 1521, 2483, 2484, 3306, 5432, 6379, 9200, 9300, 27017, 27018, 27019];
10400
10655
  var ETIQUETAS_INTERNET = /* @__PURE__ */ new Set(["*", "internet", "any", "/0"]);
10401
10656
  function longitudPrefijo(cidr, maximo) {
10402
10657
  const partes = cidr.split("/");
@@ -10437,13 +10692,21 @@ function protocolName(protocol) {
10437
10692
  const p = protocol.trim().toLowerCase();
10438
10693
  return POR_NUMERO2[p] ?? p;
10439
10694
  }
10440
- function adminPortsReached(protocol, portRanges) {
10695
+ function portsReached(ports, protocol, portRanges) {
10441
10696
  const p = protocolName(protocol);
10442
10697
  const todos = p === "*" || p === "any" || p === "all";
10443
- if (todos) return [...ADMIN_PORTS2];
10698
+ if (todos) return [...ports];
10444
10699
  if (p !== "tcp" && p !== "udp") return [];
10445
- if (portRanges.length === 0) return [...ADMIN_PORTS2];
10446
- return ADMIN_PORTS2.filter((puerto) => portRanges.some((rango) => cubrePuerto(rango, puerto)));
10700
+ if (portRanges.length === 0) return [...ports];
10701
+ return ports.filter((puerto) => portRanges.some((rango) => cubrePuerto(rango, puerto)));
10702
+ }
10703
+ function adminPortsReached(protocol, portRanges) {
10704
+ return portsReached(ADMIN_PORTS2, protocol, portRanges);
10705
+ }
10706
+ function databasePortsReached(protocol, portRanges) {
10707
+ if (protocolName(protocol) === "udp") return [];
10708
+ if (adminPortsReached(protocol, portRanges).length > 0) return [];
10709
+ return portsReached(DATABASE_PORTS, protocol, portRanges);
10447
10710
  }
10448
10711
  function cadenas2(value) {
10449
10712
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
@@ -10679,6 +10942,67 @@ var adminAbiertoPorPrefixList = {
10679
10942
  requires: [REGION_CHECKS.listRegions, EC2_CHECKS.securityGroups, EC2_CHECKS.prefixListEntries],
10680
10943
  evaluate: prefixListEvaluate
10681
10944
  };
10945
+ function puertosDeBaseDeDatos(regla) {
10946
+ if (typeof regla.protocol !== "string") return void 0;
10947
+ const protocolo = protocolName(regla.protocol);
10948
+ if (protocolo !== "tcp") return [];
10949
+ const desde = regla.fromPort;
10950
+ const hasta = regla.toPort;
10951
+ if (typeof desde !== "number" || typeof hasta !== "number") return void 0;
10952
+ if (ADMIN_PORTS2.some((puerto) => desde <= puerto && puerto <= hasta)) return [];
10953
+ return DATABASE_PORTS.filter((puerto) => desde <= puerto && puerto <= hasta);
10954
+ }
10955
+ function bdAbiertaEvaluate(observation) {
10956
+ const ingress = observation.attributes.ingressRules;
10957
+ if (!Array.isArray(ingress)) {
10958
+ return { status: "unknown", check: EC2_CHECKS.securityGroups, attribute: "ingressRules" };
10959
+ }
10960
+ const aperturas = [];
10961
+ const puertos = /* @__PURE__ */ new Set();
10962
+ let indecisa = false;
10963
+ for (const entrada of ingress) {
10964
+ if (typeof entrada !== "object" || entrada === null) {
10965
+ indecisa = true;
10966
+ continue;
10967
+ }
10968
+ const regla = entrada;
10969
+ const origenes = [...cadenas2(regla.cidrs), ...cadenas2(regla.ipv6Cidrs)];
10970
+ const abierto = anySourceOpensToInternet(origenes);
10971
+ if (abierto === void 0) {
10972
+ indecisa = true;
10973
+ continue;
10974
+ }
10975
+ if (!abierto) continue;
10976
+ const alcanzados = puertosDeBaseDeDatos(regla);
10977
+ if (alcanzados === void 0) {
10978
+ indecisa = true;
10979
+ continue;
10980
+ }
10981
+ if (alcanzados.length === 0) continue;
10982
+ for (const puerto of alcanzados) puertos.add(puerto);
10983
+ aperturas.push({
10984
+ protocol: regla.protocol,
10985
+ fromPort: regla.fromPort ?? null,
10986
+ toPort: regla.toPort ?? null,
10987
+ cidr: origenes.find((cidr) => sourceOpensToInternet(cidr) === true) ?? null
10988
+ });
10989
+ }
10990
+ if (aperturas.length > 0) {
10991
+ return { status: "finding", evidence: { ports: [...puertos].sort((a, b) => a - b), openings: aperturas } };
10992
+ }
10993
+ if (indecisa) return { status: "unknown", check: EC2_CHECKS.securityGroups, attribute: "ingressRules" };
10994
+ return { status: "ok" };
10995
+ }
10996
+ var bdAbiertaAInternet = {
10997
+ id: "SEC_AWS_NETWORK_BD_ABIERTO",
10998
+ scope: "resource",
10999
+ category: "security_network",
11000
+ severity: "high",
11001
+ provider: "aws",
11002
+ kinds: ["security_group"],
11003
+ requires: [REGION_CHECKS.listRegions, EC2_CHECKS.securityGroups],
11004
+ evaluate: bdAbiertaEvaluate
11005
+ };
10682
11006
 
10683
11007
  // ../../services/security/src/rules/aws/monitoring.ts
10684
11008
  var evento = (...nombres) => nombres.map((n) => `$.eventName=${n}`);
@@ -11010,17 +11334,21 @@ var secretoEnVariableDeEntorno = {
11010
11334
  var AWS_SECURITY_RULES = [
11011
11335
  adminAbiertoAInternet,
11012
11336
  adminAbiertoPorPrefixList,
11337
+ bdAbiertaAInternet,
11013
11338
  lightsailAdminAbierto,
11014
11339
  nlbAdminAbierto,
11015
11340
  bucketPublico,
11016
11341
  ebsSnapshotPublico,
11017
11342
  rdsSnapshotPublico,
11018
11343
  rdsClusterSnapshotPublico,
11344
+ rdsPublica,
11019
11345
  lambdaPublica,
11020
11346
  kmsSinRotacion,
11021
11347
  rdsSinCifrar,
11022
11348
  rootSinMfa,
11023
11349
  usuarioSinMfa,
11350
+ claveAntigua,
11351
+ politicaContrasenas,
11024
11352
  adminEfectivo,
11025
11353
  identidadSinUso,
11026
11354
  claveSinUso,
@@ -11073,7 +11401,18 @@ var AZURE_STORAGE_CHECKS = {
11073
11401
  };
11074
11402
  var AZURE_SQL_CHECKS = {
11075
11403
  list: "azure:sql:list",
11076
- firewallRules: "azure:sql:firewallRules"
11404
+ firewallRules: "azure:sql:firewallRules",
11405
+ databases: "azure:sql:databases",
11406
+ transparentDataEncryption: "azure:sql:transparentDataEncryption"
11407
+ };
11408
+ var AZURE_KEYVAULT_CHECKS = {
11409
+ vaults: "azure:keyvault:vaults"
11410
+ };
11411
+ var AZURE_NETWORK_CHECKS = {
11412
+ virtualNetworks: "azure:network:virtualNetworks",
11413
+ networkWatchers: "azure:network:networkWatchers",
11414
+ flowLogs: "azure:network:flowLogs",
11415
+ networkInterfaces: "azure:network:networkInterfaces"
11077
11416
  };
11078
11417
  var AZURE_IAM_CHECKS = {
11079
11418
  roleAssignments: "azure:iam:roleAssignments",
@@ -11132,6 +11471,44 @@ var sqlTlsDebil = {
11132
11471
  requires: [AZURE_SQL_CHECKS.list],
11133
11472
  evaluate: sqlTlsEvaluate
11134
11473
  };
11474
+ function keyVaultSinPurgaEvaluate(observation) {
11475
+ if (!("purgeProtection" in observation.attributes)) {
11476
+ return { status: "unknown", check: AZURE_KEYVAULT_CHECKS.vaults, attribute: "purgeProtection" };
11477
+ }
11478
+ if (observation.attributes.purgeProtection === true) return { status: "ok" };
11479
+ return {
11480
+ status: "finding",
11481
+ evidence: { purgeProtection: false, softDelete: observation.attributes.softDelete ?? null }
11482
+ };
11483
+ }
11484
+ var keyVaultSinPurga = {
11485
+ id: "SEC_AZURE_ENCRYPTION_KEYVAULT_SIN_PURGA",
11486
+ scope: "resource",
11487
+ category: "security_encryption",
11488
+ severity: "medium",
11489
+ provider: "azure",
11490
+ kinds: ["key_vault"],
11491
+ requires: [AZURE_KEYVAULT_CHECKS.vaults],
11492
+ evaluate: keyVaultSinPurgaEvaluate
11493
+ };
11494
+ function sqlSinTdeEvaluate(observation) {
11495
+ const estado = observation.attributes.tdeState;
11496
+ if (typeof estado !== "string") {
11497
+ return { status: "unknown", check: AZURE_SQL_CHECKS.transparentDataEncryption, attribute: "tdeState" };
11498
+ }
11499
+ if (estado.toLowerCase() === "enabled") return { status: "ok" };
11500
+ return { status: "finding", evidence: { tdeState: estado } };
11501
+ }
11502
+ var sqlSinTde = {
11503
+ id: "SEC_AZURE_ENCRYPTION_SQL_SIN_TDE",
11504
+ scope: "resource",
11505
+ category: "security_encryption",
11506
+ severity: "high",
11507
+ provider: "azure",
11508
+ kinds: ["sql_database"],
11509
+ requires: [AZURE_SQL_CHECKS.list, AZURE_SQL_CHECKS.databases, AZURE_SQL_CHECKS.transparentDataEncryption],
11510
+ evaluate: sqlSinTdeEvaluate
11511
+ };
11135
11512
 
11136
11513
  // ../../services/security/src/rules/azure/exposure.ts
11137
11514
  function blobPublicoEvaluate(observation) {
@@ -11286,9 +11663,35 @@ var sinDiagnosticSettings = {
11286
11663
  requires: [AZURE_LOGGING_CHECKS.diagnosticSettings],
11287
11664
  evaluate: evaluate5
11288
11665
  };
11666
+ function vnetSinFlowLogsEvaluate(observation) {
11667
+ const vnet = observation.attributes.vnetFlowLog;
11668
+ if (typeof vnet !== "boolean") {
11669
+ return { status: "unknown", check: AZURE_NETWORK_CHECKS.flowLogs, attribute: "vnetFlowLog" };
11670
+ }
11671
+ if (vnet) return { status: "ok" };
11672
+ const subnets = observation.attributes.subnets;
11673
+ if (!Array.isArray(subnets)) {
11674
+ return { status: "unknown", check: AZURE_NETWORK_CHECKS.networkInterfaces, attribute: "subnets" };
11675
+ }
11676
+ const sinRegistro = subnets.filter((subnet) => subnet.covered !== true && subnet.platform !== true).map((subnet) => subnet.name);
11677
+ if (sinRegistro.length === 0) return { status: "ok" };
11678
+ return { status: "finding", evidence: { unloggedSubnets: sinRegistro, subnetCount: subnets.length } };
11679
+ }
11680
+ var vnetSinFlowLogs = {
11681
+ id: "SEC_AZURE_LOGGING_VNET_SIN_FLOW_LOGS",
11682
+ scope: "resource",
11683
+ category: "security_logging",
11684
+ severity: "medium",
11685
+ provider: "azure",
11686
+ kinds: ["virtual_network"],
11687
+ // Las interfaces no van aquí: solo cuentan para subredes sin log propio, y su falta ya sale como `unknown` de esa VNet.
11688
+ requires: [AZURE_NETWORK_CHECKS.virtualNetworks, AZURE_NETWORK_CHECKS.networkWatchers, AZURE_NETWORK_CHECKS.flowLogs],
11689
+ evaluate: vnetSinFlowLogsEvaluate
11690
+ };
11289
11691
 
11290
11692
  // ../../services/security/src/rules/azure/network.ts
11291
- function evaluate6(observation) {
11693
+ var evaluate6 = (observation) => nsgAbierto(observation, adminPortsReached);
11694
+ function nsgAbierto(observation, reached) {
11292
11695
  const ingress = observation.attributes.ingressRules;
11293
11696
  if (!Array.isArray(ingress)) {
11294
11697
  return { status: "unknown", check: AZURE_NSG_CHECKS.list, attribute: "ingressRules" };
@@ -11310,7 +11713,7 @@ function evaluate6(observation) {
11310
11713
  }
11311
11714
  if (!abierto) continue;
11312
11715
  const protocol = typeof regla.protocol === "string" ? regla.protocol : "";
11313
- const alcanzados = adminPortsReached(protocol, cadenas2(regla.ports));
11716
+ const alcanzados = reached(protocol, cadenas2(regla.ports));
11314
11717
  if (alcanzados.length === 0) continue;
11315
11718
  for (const puerto of alcanzados) puertos.add(puerto);
11316
11719
  openings.push({ protocol: regla.protocol, ports: alcanzados, sources: regla.sources });
@@ -11333,24 +11736,38 @@ var nsgAdminAbierto = {
11333
11736
  requires: [AZURE_NSG_CHECKS.list],
11334
11737
  evaluate: evaluate6
11335
11738
  };
11739
+ var nsgBdAbierto = {
11740
+ id: "SEC_AZURE_NETWORK_BD_ABIERTO",
11741
+ scope: "resource",
11742
+ category: "security_network",
11743
+ severity: "high",
11744
+ provider: "azure",
11745
+ kinds: ["nsg"],
11746
+ requires: [AZURE_NSG_CHECKS.list],
11747
+ evaluate: (observation) => nsgAbierto(observation, databasePortsReached)
11748
+ };
11336
11749
 
11337
11750
  // ../../services/security/src/rules/azure/index.ts
11338
11751
  var AZURE_SECURITY_RULES = [
11339
11752
  nsgAdminAbierto,
11753
+ nsgBdAbierto,
11340
11754
  storageBlobPublico,
11341
11755
  sqlPublico,
11342
11756
  storageTlsDebil,
11343
11757
  sqlTlsDebil,
11758
+ keyVaultSinPurga,
11759
+ sqlSinTde,
11344
11760
  rolPrivilegiado,
11345
11761
  asignacionHuerfana,
11346
- sinDiagnosticSettings
11762
+ sinDiagnosticSettings,
11763
+ vnetSinFlowLogs
11347
11764
  ];
11348
11765
 
11349
11766
  // ../../services/security/src/rules/gcp/combos.ts
11350
11767
  var minus = (s) => typeof s === "string" ? s.toLowerCase() : "";
11351
11768
  var GCP_SECURITY_COMBOS = [
11352
11769
  {
11353
- // Abierta a internet, sin una conexión en 7 días y facturando: se paga por una puerta que nadie usa.
11770
+ // Abierta a internet, sin uso en 7 días y facturando: se paga por una puerta que nadie usa.
11354
11771
  id: "COMBO_GCP_PUBLIC_SQL_IDLE",
11355
11772
  provider: "gcp",
11356
11773
  severity: "high",
@@ -11415,11 +11832,25 @@ var GCP_COMPUTE_CHECKS = {
11415
11832
  effectiveFirewalls: "gcp:compute:networks.getEffectiveFirewalls",
11416
11833
  regions: "gcp:compute:regions",
11417
11834
  regionFirewallPolicies: "gcp:compute:regionNetworkFirewallPolicies",
11418
- sharedVpcHost: "gcp:compute:sharedVpcHostFirewalls"
11835
+ sharedVpcHost: "gcp:compute:sharedVpcHostFirewalls",
11836
+ subnetworks: "gcp:compute:subnetworks"
11837
+ };
11838
+ var GCP_NETWORK_MANAGEMENT_CHECKS = {
11839
+ vpcFlowLogsConfigs: "gcp:networkmanagement:vpcFlowLogsConfigs"
11840
+ };
11841
+ var GCP_KMS_CHECKS = {
11842
+ locations: "gcp:kms:locations",
11843
+ keyRings: "gcp:kms:keyRings",
11844
+ cryptoKeys: "gcp:kms:cryptoKeys"
11419
11845
  };
11420
11846
  var GCP_SQL_CHECKS = {
11421
11847
  instances: "gcp:sql:instances"
11422
11848
  };
11849
+ var GCP_STORAGE_CHECKS = {
11850
+ buckets: "gcp:storage:buckets",
11851
+ // storage.buckets.getIamPolicy no está en roles/viewer ni en roles/storage.viewer: lo trae roles/iam.securityReviewer.
11852
+ iamPolicy: "gcp:storage:buckets.getIamPolicy"
11853
+ };
11423
11854
  var GCP_FUNCTIONS_CHECKS = {
11424
11855
  list: "gcp:functions:list"
11425
11856
  };
@@ -11480,6 +11911,63 @@ var sqlSinCmek = {
11480
11911
  requires: [GCP_SQL_CHECKS.instances],
11481
11912
  evaluate: sqlSinCmekEvaluate
11482
11913
  };
11914
+ var SOLO_CIFRADO = /* @__PURE__ */ new Set(["ENCRYPTED_ONLY", "TRUSTED_CLIENT_CERTIFICATE_REQUIRED"]);
11915
+ function sqlSinSslEvaluate(observation) {
11916
+ const modo = observation.attributes.sslMode;
11917
+ if (typeof modo === "string" && modo.length > 0) {
11918
+ if (SOLO_CIFRADO.has(modo)) return { status: "ok" };
11919
+ return { status: "finding", evidence: { sslMode: modo, ipv4Enabled: observation.attributes.ipv4Enabled ?? null } };
11920
+ }
11921
+ const requiere = observation.attributes.requireSsl;
11922
+ if (requiere === true) return { status: "ok" };
11923
+ if (!("sslMode" in observation.attributes)) {
11924
+ return { status: "unknown", check: GCP_SQL_CHECKS.instances, attribute: "sslMode" };
11925
+ }
11926
+ if (requiere !== false && requiere !== null) {
11927
+ return { status: "unknown", check: GCP_SQL_CHECKS.instances, attribute: "requireSsl" };
11928
+ }
11929
+ return { status: "finding", evidence: { sslMode: null, requireSsl: requiere, ipv4Enabled: observation.attributes.ipv4Enabled ?? null } };
11930
+ }
11931
+ var sqlSinSsl = {
11932
+ id: "SEC_GCP_ENCRYPTION_SQL_SIN_SSL",
11933
+ scope: "resource",
11934
+ category: "security_encryption",
11935
+ severity: "medium",
11936
+ provider: "gcp",
11937
+ kinds: ["cloud_sql_instance"],
11938
+ requires: [GCP_SQL_CHECKS.instances],
11939
+ evaluate: sqlSinSslEvaluate
11940
+ };
11941
+ var KMS_MAX_ROTATION_DAYS = 90;
11942
+ function segundos(periodo) {
11943
+ const m = /^(\d+(?:\.\d+)?)s$/.exec(periodo);
11944
+ return m ? Number(m[1]) : void 0;
11945
+ }
11946
+ function kmsSinRotacionEvaluate2(observation) {
11947
+ const a = observation.attributes;
11948
+ if (!("purpose" in a) || !("primaryState" in a) || !("rotationPeriod" in a)) {
11949
+ return { status: "unknown", check: GCP_KMS_CHECKS.cryptoKeys, attribute: "rotationPeriod" };
11950
+ }
11951
+ if (a.purpose !== "ENCRYPT_DECRYPT" || a.primaryState !== "ENABLED") return { status: "ok" };
11952
+ if (a.importOnly === true || typeof a.protectionLevel === "string" && a.protectionLevel.startsWith("EXTERNAL")) {
11953
+ return { status: "ok" };
11954
+ }
11955
+ if (a.rotationPeriod === null) return { status: "finding", evidence: { rotationPeriod: null } };
11956
+ const s = typeof a.rotationPeriod === "string" ? segundos(a.rotationPeriod) : void 0;
11957
+ if (s === void 0) return { status: "unknown", check: GCP_KMS_CHECKS.cryptoKeys, attribute: "rotationPeriod" };
11958
+ if (s <= KMS_MAX_ROTATION_DAYS * 86400) return { status: "ok" };
11959
+ return { status: "finding", evidence: { rotationPeriodDays: Math.round(s / 86400) } };
11960
+ }
11961
+ var kmsSinRotacion2 = {
11962
+ id: "SEC_GCP_ENCRYPTION_KMS_SIN_ROTACION",
11963
+ scope: "resource",
11964
+ category: "security_encryption",
11965
+ severity: "medium",
11966
+ provider: "gcp",
11967
+ kinds: ["kms_key"],
11968
+ requires: [GCP_KMS_CHECKS.locations, GCP_KMS_CHECKS.keyRings, GCP_KMS_CHECKS.cryptoKeys],
11969
+ evaluate: kmsSinRotacionEvaluate2
11970
+ };
11483
11971
 
11484
11972
  // ../../services/security/src/rules/gcp/exposure.ts
11485
11973
  function evaluate7(observation) {
@@ -11494,6 +11982,28 @@ function evaluate7(observation) {
11494
11982
  evidence: { authorizedNetworks: nets }
11495
11983
  };
11496
11984
  }
11985
+ function gcsPublicoEvaluate(observation) {
11986
+ if (observation.attributes.publicAccessPrevention === "enforced") return { status: "ok" };
11987
+ const bindings = observation.attributes.publicBindings;
11988
+ if (!Array.isArray(bindings)) {
11989
+ return { status: "unknown", check: GCP_STORAGE_CHECKS.iamPolicy, attribute: "publicBindings" };
11990
+ }
11991
+ if (bindings.length === 0) return { status: "ok" };
11992
+ return {
11993
+ status: "finding",
11994
+ evidence: { publicBindings: bindings, publicAccessPrevention: observation.attributes.publicAccessPrevention ?? null }
11995
+ };
11996
+ }
11997
+ var gcsPublico = {
11998
+ id: "SEC_GCP_EXPOSURE_GCS_PUBLICO",
11999
+ scope: "resource",
12000
+ category: "security_exposure",
12001
+ severity: "critical",
12002
+ provider: "gcp",
12003
+ kinds: ["gcs_bucket"],
12004
+ requires: [GCP_STORAGE_CHECKS.buckets, GCP_STORAGE_CHECKS.iamPolicy],
12005
+ evaluate: gcsPublicoEvaluate
12006
+ };
11497
12007
  var sqlPublico2 = {
11498
12008
  id: "SEC_GCP_EXPOSURE_SQL_PUBLICO",
11499
12009
  scope: "resource",
@@ -11693,9 +12203,42 @@ var sinExportSink = {
11693
12203
  requires: [GCP_LOGGING_CHECKS.sinks],
11694
12204
  evaluate: evaluate8
11695
12205
  };
12206
+ function vpcSinFlowLogsEvaluate(observation) {
12207
+ const subnets = observation.attributes.subnets;
12208
+ if (!Array.isArray(subnets)) {
12209
+ return { status: "unknown", check: GCP_COMPUTE_CHECKS.subnetworks, attribute: "subnets" };
12210
+ }
12211
+ const lista = subnets;
12212
+ const sinRegistro = lista.filter((subnet) => subnet.covered === false);
12213
+ if (sinRegistro.length > 0) {
12214
+ return {
12215
+ status: "finding",
12216
+ evidence: { unloggedSubnets: sinRegistro.map((subnet) => `${String(subnet.region)}/${String(subnet.name)}`), subnetCount: lista.length }
12217
+ };
12218
+ }
12219
+ if (lista.some((subnet) => subnet.covered !== true)) {
12220
+ return { status: "unknown", check: GCP_NETWORK_MANAGEMENT_CHECKS.vpcFlowLogsConfigs, attribute: "subnets" };
12221
+ }
12222
+ if (observation.attributes.subnetsComplete !== true) {
12223
+ return { status: "unknown", check: GCP_COMPUTE_CHECKS.subnetworks, attribute: "subnets" };
12224
+ }
12225
+ return { status: "ok" };
12226
+ }
12227
+ var vpcSinFlowLogs = {
12228
+ id: "SEC_GCP_LOGGING_VPC_SIN_FLOW_LOGS",
12229
+ scope: "resource",
12230
+ category: "security_logging",
12231
+ severity: "medium",
12232
+ provider: "gcp",
12233
+ kinds: ["vpc_network"],
12234
+ // Las configs de Network Management no van aquí: una red con `logConfig` en cada subred ya está bien sin ellas.
12235
+ requires: [GCP_COMPUTE_CHECKS.subnetworks],
12236
+ evaluate: vpcSinFlowLogsEvaluate
12237
+ };
11696
12238
 
11697
12239
  // ../../services/security/src/rules/gcp/network.ts
11698
- function evaluate9(observation) {
12240
+ var evaluate9 = (observation) => firewallAbierto(observation, adminPortsReached);
12241
+ function firewallAbierto(observation, reached) {
11699
12242
  const direction = observation.attributes.direction;
11700
12243
  const disabled = observation.attributes.disabled;
11701
12244
  const sources = observation.attributes.sourceRanges;
@@ -11714,7 +12257,7 @@ function evaluate9(observation) {
11714
12257
  if (typeof entry !== "object" || entry === null) continue;
11715
12258
  const rule = entry;
11716
12259
  const protocol = typeof rule.protocol === "string" ? rule.protocol : "";
11717
- for (const puerto of adminPortsReached(protocol, cadenas2(rule.ports))) puertos.add(puerto);
12260
+ for (const puerto of reached(protocol, cadenas2(rule.ports))) puertos.add(puerto);
11718
12261
  }
11719
12262
  if (puertos.size === 0) return { status: "ok" };
11720
12263
  return {
@@ -11874,6 +12417,16 @@ var firewallAdminAbierto = {
11874
12417
  requires: [GCP_COMPUTE_CHECKS.firewalls],
11875
12418
  evaluate: evaluate9
11876
12419
  };
12420
+ var firewallBdAbierto = {
12421
+ id: "SEC_GCP_NETWORK_BD_ABIERTO",
12422
+ scope: "resource",
12423
+ category: "security_network",
12424
+ severity: "high",
12425
+ provider: "gcp",
12426
+ kinds: ["firewall"],
12427
+ requires: [GCP_COMPUTE_CHECKS.firewalls],
12428
+ evaluate: (observation) => firewallAbierto(observation, databasePortsReached)
12429
+ };
11877
12430
 
11878
12431
  // ../../services/security/src/rules/gcp/secrets.ts
11879
12432
  var SOSPECHOSAS2 = ["PASSWORD", "PASSWD", "SECRET", "TOKEN", "APIKEY", "API_KEY", "PRIVATE_KEY", "CREDENTIAL"];
@@ -11908,10 +12461,14 @@ var secretoEnFunction = {
11908
12461
  // ../../services/security/src/rules/gcp/index.ts
11909
12462
  var GCP_SECURITY_RULES = [
11910
12463
  firewallAdminAbierto,
12464
+ firewallBdAbierto,
11911
12465
  politicaAdminAbierta,
11912
12466
  sqlPublico2,
12467
+ gcsPublico,
11913
12468
  discoSinCmek,
11914
12469
  sqlSinCmek,
12470
+ sqlSinSsl,
12471
+ kmsSinRotacion2,
11915
12472
  secretoEnFunction,
11916
12473
  rolPrimitivo,
11917
12474
  claveSaUserManaged,
@@ -11919,7 +12476,8 @@ var GCP_SECURITY_RULES = [
11919
12476
  claveSaSinUso,
11920
12477
  permisosSinUso2,
11921
12478
  auditIncompleto,
11922
- sinExportSink
12479
+ sinExportSink,
12480
+ vpcSinFlowLogs
11923
12481
  ];
11924
12482
 
11925
12483
  // src/security/evaluate.ts
@@ -11953,7 +12511,8 @@ var UNCOVERED_TYPES = /* @__PURE__ */ new Set([
11953
12511
  "google_compute_region_network_firewall_policy_rule",
11954
12512
  "google_logging_project_sink",
11955
12513
  "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.
12514
+ // SEC_GCP_EXPOSURE_GCS_PUBLICO juzga el bucket desplegado; el plan reparte su política en tres tipos que aún no se cruzan.
12515
+ "google_storage_bucket",
11957
12516
  "google_storage_bucket_iam_member",
11958
12517
  "google_storage_bucket_iam_binding",
11959
12518
  "google_storage_bucket_iam_policy"
@@ -12030,14 +12589,14 @@ function evaluatePlan(plan) {
12030
12589
  }))].sort();
12031
12590
  const accountRules = ALL_RULES.filter((rule) => rule.scope === "account" && providers.includes(rule.provider)).map((rule) => rule.id);
12032
12591
  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);
12592
+ const bySeverity2 = (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.address.localeCompare(b.address);
12034
12593
  return {
12035
12594
  report: {
12036
12595
  providers,
12037
12596
  resourcesEvaluated: new Set(observations.map((observation) => observation.address)).size,
12038
- findings: planFindings.sort(bySeverity),
12039
- combos: combos.sort(bySeverity),
12040
- notEvaluable: notEvaluable.sort(bySeverity),
12597
+ findings: planFindings.sort(bySeverity2),
12598
+ combos: combos.sort(bySeverity2),
12599
+ notEvaluable: notEvaluable.sort(bySeverity2),
12041
12600
  accountRules,
12042
12601
  uncoveredTypes,
12043
12602
  passed
@@ -12053,7 +12612,7 @@ function blockingCount(report, threshold) {
12053
12612
 
12054
12613
  // src/security/render.ts
12055
12614
  import { createHash as createHash2 } from "node:crypto";
12056
- var SEVERITIES2 = ["critical", "high", "medium", "low"];
12615
+ var SEVERITIES3 = ["critical", "high", "medium", "low"];
12057
12616
  var BADGE = { critical: "\u{1F534} critical", high: "\u{1F7E0} high", medium: "\u{1F7E1} medium", low: "\u26AA low" };
12058
12617
  var GAP_TEXT = {
12059
12618
  known_after_apply: "value only known after apply",
@@ -12085,7 +12644,7 @@ function evidenceSummary(finding) {
12085
12644
  return parts.join("; ");
12086
12645
  }
12087
12646
  function counts(report) {
12088
- return SEVERITIES2.map((severity) => `${report.findings.filter((f) => f.severity === severity).length} ${severity}`).join(", ");
12647
+ return SEVERITIES3.map((severity) => `${report.findings.filter((f) => f.severity === severity).length} ${severity}`).join(", ");
12089
12648
  }
12090
12649
  function displayAddress(address) {
12091
12650
  return address.replace(/\["((?:[^"\\]|\\.)*)"\]/g, (_, key) => `[#${createHash2("sha256").update(key).digest("hex").slice(0, 8)}]`);
@@ -12104,18 +12663,18 @@ function groupedGaps(items) {
12104
12663
  function renderSecurityMarkdown(report) {
12105
12664
  const drift = report.drift?.items ?? [];
12106
12665
  if (report.resourcesEvaluated === 0 && report.uncoveredTypes.length === 0 && drift.length === 0) return null;
12107
- const lines = ["### \u{1F512} Security", ""];
12666
+ const lines2 = ["### \u{1F512} Security", ""];
12108
12667
  if (report.resourcesEvaluated === 0) {
12109
- lines.push("_No resource in this plan is covered by the security rules yet._");
12668
+ lines2.push("_No resource in this plan is covered by the security rules yet._");
12110
12669
  } else if (report.findings.length === 0) {
12111
- lines.push(
12670
+ lines2.push(
12112
12671
  `**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
12672
  );
12114
12673
  } else {
12115
- lines.push(`**Findings: ${counts(report)}** on ${report.resourcesEvaluated} resources checked.`);
12674
+ lines2.push(`**Findings: ${counts(report)}** on ${report.resourcesEvaluated} resources checked.`);
12116
12675
  }
12117
12676
  if (report.findings.length > 0) {
12118
- lines.push("", "| Severity | Rule | Resource | Change | Detail |", "|---|---|---|---|---|");
12677
+ lines2.push("", "| Severity | Rule | Resource | Change | Detail |", "|---|---|---|---|---|");
12119
12678
  const rows = /* @__PURE__ */ new Map();
12120
12679
  for (const finding of report.findings) {
12121
12680
  const key = `${finding.ruleId}#${finding.address}`;
@@ -12125,35 +12684,35 @@ function renderSecurityMarkdown(report) {
12125
12684
  }
12126
12685
  for (const { finding, count } of rows.values()) {
12127
12686
  const detail = [evidenceSummary(finding), count > 1 ? `\xD7${count}` : ""].filter(Boolean).join(" ");
12128
- lines.push(
12687
+ lines2.push(
12129
12688
  `| ${BADGE[finding.severity]} | ${cell(ruleTitle(finding.ruleId))} | ${code(finding.address)} | ${ACTION[finding.action] ?? finding.action} | ${cell(detail) || "\u2014"} |`
12130
12689
  );
12131
12690
  }
12132
12691
  }
12133
12692
  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 |", "|---|---|---|");
12693
+ lines2.push("", "**Toxic combinations** \u2014 findings that are worse together, fix these first:", "");
12694
+ lines2.push("| Severity | Combination | Resource |", "|---|---|---|");
12136
12695
  for (const combo of report.combos) {
12137
- lines.push(`| ${BADGE[combo.severity]} | ${cell(comboTitle(combo.comboId))} | ${code(combo.address)} |`);
12696
+ lines2.push(`| ${BADGE[combo.severity]} | ${cell(comboTitle(combo.comboId))} | ${code(combo.address)} |`);
12138
12697
  }
12139
12698
  }
12140
12699
  if (drift.length > 0) {
12141
12700
  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 |", "|---|---|---|---|");
12701
+ lines2.push("", `**Last scan vs. this plan** (AAA Sentinel scan${when}):`, "");
12702
+ lines2.push("| Severity | Rule | Resource | What it means |", "|---|---|---|---|");
12144
12703
  for (const item of drift) {
12145
- lines.push(`| ${BADGE[item.severity]} | ${cell(ruleTitle(item.ruleId))} | ${code(item.address)} | ${DRIFT_TEXT[item.status]} |`);
12704
+ lines2.push(`| ${BADGE[item.severity]} | ${cell(ruleTitle(item.ruleId))} | ${code(item.address)} | ${DRIFT_TEXT[item.status]} |`);
12146
12705
  }
12147
12706
  }
12148
12707
  if (report.drift?.truncated) {
12149
- lines.push("", "_The live scan returned only its 500 most severe findings: drift on lower ones may be missing._");
12708
+ lines2.push("", "_The live scan returned only its 500 most severe findings: drift on lower ones may be missing._");
12150
12709
  }
12151
12710
  const gaps = groupedGaps(report.notEvaluable);
12152
12711
  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>");
12712
+ lines2.push("", `<details><summary>Not evaluable before apply (${gaps.length})</summary>`, "");
12713
+ lines2.push("| Rule | Resource | Why |", "|---|---|---|");
12714
+ for (const gap of gaps) lines2.push(`| ${cell(ruleTitle(gap.ruleId))} | ${code(gap.address)} | ${GAP_TEXT[gap.reason]} |`);
12715
+ lines2.push("", "</details>");
12157
12716
  }
12158
12717
  const notes = [];
12159
12718
  if (report.accountRules.length > 0) {
@@ -12163,31 +12722,31 @@ function renderSecurityMarkdown(report) {
12163
12722
  notes.push(`Present in this plan but not checked before deploy: ${report.uncoveredTypes.map(code).join(", ")}.`);
12164
12723
  }
12165
12724
  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");
12725
+ lines2.push("", ...notes.map((note) => `> ${note}`));
12726
+ return lines2.join("\n");
12168
12727
  }
12169
12728
  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)}`);
12729
+ const lines2 = ["Security"];
12730
+ if (report.resourcesEvaluated === 0) lines2.push(" No resource in this plan is covered by the security rules yet.");
12731
+ else lines2.push(` ${report.resourcesEvaluated} resources checked: ${counts(report)}`);
12173
12732
  for (const finding of report.findings) {
12174
12733
  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, "")}` : ""}`);
12734
+ lines2.push(` [${finding.severity.toUpperCase()}] ${ruleTitle(finding.ruleId)} - ${displayAddress(finding.address)} (${ACTION[finding.action] ?? finding.action})${detail ? ` - ${detail.replace(/`/g, "")}` : ""}`);
12176
12735
  }
12177
12736
  for (const combo of report.combos) {
12178
- lines.push(` [${combo.severity.toUpperCase()}] combination: ${comboTitle(combo.comboId)} - ${displayAddress(combo.address)}`);
12737
+ lines2.push(` [${combo.severity.toUpperCase()}] combination: ${comboTitle(combo.comboId)} - ${displayAddress(combo.address)}`);
12179
12738
  }
12180
12739
  for (const item of report.drift?.items ?? []) {
12181
- lines.push(` [LAST SCAN] ${ruleTitle(item.ruleId)} - ${displayAddress(item.address)}: ${DRIFT_TEXT[item.status]}`);
12740
+ lines2.push(` [LAST SCAN] ${ruleTitle(item.ruleId)} - ${displayAddress(item.address)}: ${DRIFT_TEXT[item.status]}`);
12182
12741
  }
12183
12742
  const gaps = groupedGaps(report.notEvaluable);
12184
12743
  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]}`);
12744
+ lines2.push(` Not evaluable before apply (${gaps.length}):`);
12745
+ for (const gap of gaps) lines2.push(` - ${ruleTitle(gap.ruleId)} - ${displayAddress(gap.address)}: ${GAP_TEXT[gap.reason]}`);
12187
12746
  }
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");
12747
+ if (report.accountRules.length > 0) lines2.push(` ${report.accountRules.length} account-wide rules are only checked by the live scan.`);
12748
+ if (report.uncoveredTypes.length > 0) lines2.push(` Not checked before deploy: ${report.uncoveredTypes.join(", ")}`);
12749
+ return lines2.join("\n");
12191
12750
  }
12192
12751
 
12193
12752
  // src/run.ts
@@ -12230,10 +12789,10 @@ function decodePlan(bytes) {
12230
12789
  var fmtUsd2 = (n) => `${n < 0 ? "-" : "+"}$${Math.abs(n).toFixed(2)}/mo`;
12231
12790
  function costText(estimate) {
12232
12791
  const { coverage } = estimate;
12233
- const total = estimate.totalMonthlyDeltaUsd === null ? "no total (coverage too low)" : `${fmtUsd2(estimate.totalMonthlyDeltaUsd)} (list price, USD)`;
12792
+ const total2 = estimate.totalMonthlyDeltaUsd === null ? "no total (coverage too low)" : `${fmtUsd2(estimate.totalMonthlyDeltaUsd)} (list price, USD)`;
12234
12793
  return [
12235
12794
  "Cost",
12236
- ` Estimated change: ${total}`,
12795
+ ` Estimated change: ${total2}`,
12237
12796
  ` Coverage: ${coverage.pricedResources} of ${coverage.pricedResources + coverage.unpricedResources} billable resources priced`
12238
12797
  ].join("\n");
12239
12798
  }
@@ -12331,8 +12890,15 @@ async function fix(argv) {
12331
12890
  if (outcome.stdout) console.log(outcome.stdout);
12332
12891
  process.exitCode = outcome.exitCode;
12333
12892
  }
12893
+ async function posture(argv) {
12894
+ const outcome = await runPostureCli(argv, { env: process.env });
12895
+ for (const warning of outcome.warnings) console.warn(`iac-cost: ${warning}`);
12896
+ if (outcome.stdout) console.log(outcome.stdout);
12897
+ process.exitCode = outcome.exitCode;
12898
+ }
12334
12899
  async function main() {
12335
12900
  if (process.argv[2] === "fix") return fix(process.argv.slice(3));
12901
+ if (process.argv[2] === "posture") return posture(process.argv.slice(3));
12336
12902
  const options = parseCliArgs(process.argv.slice(2), process.env);
12337
12903
  if ("error" in options) {
12338
12904
  console.error(`iac-cost: ${options.error}