@aaa-sentinel/iac-cost 0.4.1 → 0.5.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 +26 -2
  2. package/dist/cli.js +237 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -139,8 +139,32 @@ or account ids. The JSON output does not: it is the complete evidence (see `--fo
139
139
  `provider` block rather than the resource, so `terraform show -json` often leaves it unknown.
140
140
  We say we do not know rather than substituting another region's price.
141
141
 
142
- Currently priced: **AWS** (EC2 on demand) and **Azure** (virtual machines). Anything else is
143
- reported as not priced, never as free.
142
+ - **Usage is not guessed.** Only what the plan fixes is priced: the instance hours (730 a month)
143
+ and, for RDS, the allocated storage plus the IOPS and throughput provisioned above what the
144
+ volume includes. Usage-based charges — data transfer, backups beyond the allocated size, I/O
145
+ requests, CPU credits of burstable classes — are not added, nor storage growth from autoscaling
146
+ (`max_allocated_storage`), and neither are RDS Extended
147
+ Support (engine versions past standard support) or paid Performance Insights retention.
148
+ - **Every price-moving attribute must be known.** An `aws_db_instance` is priced only when the
149
+ plan resolves its engine, Multi-AZ, allocated storage, storage type (and IOPS/throughput where
150
+ they are billed, and the license model for Oracle SE2). An attribute you leave out of the
151
+ configuration shows as "known after apply"; the CLI reads the plan's `configuration` (modules,
152
+ `count` and `for_each` included) and, only when the attribute is not written at all, the AWS
153
+ default is applied: `multi_az` (optional in the RDS API, which creates a Single-AZ instance when
154
+ it is not sent), `storage_type` (`io1` with `iops`, otherwise `gp3`
155
+ per the RDS API; because the provider docs still say `gp2`, it is priced only where gp2 and gp3
156
+ cost the same) and the included gp3 baseline IOPS/throughput. Such rows are marked † in the
157
+ comment with the attributes assumed. Nothing is assumed on updates, when the instance is
158
+ restored from a snapshot or replica (its storage comes from the source), or when `iops` is an
159
+ expression. An attribute written as an expression that is only known after apply is never
160
+ guessed.
161
+
162
+ Currently priced: **AWS** (EC2 on demand and RDS `aws_db_instance`: MySQL, PostgreSQL, MariaDB,
163
+ Oracle and SQL Server, Single-AZ or Multi-AZ, gp2/gp3/io1/io2) and **Azure** (virtual machines).
164
+ Aurora (`aws_rds_cluster`, `aws_rds_cluster_instance`) is not priced: its instance price depends on
165
+ the cluster's storage configuration and its storage and I/O are billed by use. Anything else is
166
+ reported as not priced, never as free. Versions of the CLI before 0.5.0 do not send the RDS
167
+ storage attributes, so their RDS instances stay not priced.
144
168
 
145
169
  ## Security before deploy
146
170
 
package/dist/cli.js CHANGED
@@ -2351,18 +2351,18 @@ var $ZodArray = /* @__PURE__ */ $constructor("$ZodArray", (inst, def) => {
2351
2351
  capSize(inst, arraySize);
2352
2352
  });
2353
2353
  function handlePropertyResult(result, final, key, input, optin, optout) {
2354
- const isPresent = key in input;
2354
+ const isPresent2 = key in input;
2355
2355
  const isOptionalOut = optout === "optional";
2356
- if (!isPresent && isOptionalOut && optin === "optional") {
2356
+ if (!isPresent2 && isOptionalOut && optin === "optional") {
2357
2357
  return;
2358
2358
  }
2359
2359
  if (result.issues.length) {
2360
- if (optin !== void 0 && isOptionalOut && !isPresent) {
2360
+ if (optin !== void 0 && isOptionalOut && !isPresent2) {
2361
2361
  return;
2362
2362
  }
2363
2363
  final.issues.push(...prefixIssues(key, result.issues));
2364
2364
  }
2365
- if (!isPresent && optin === void 0) {
2365
+ if (!isPresent2 && optin === void 0) {
2366
2366
  if (!result.issues.length) {
2367
2367
  final.issues.push({
2368
2368
  code: "invalid_type",
@@ -2374,7 +2374,7 @@ function handlePropertyResult(result, final, key, input, optin, optout) {
2374
2374
  return;
2375
2375
  }
2376
2376
  if (result.value === void 0) {
2377
- if (isPresent || optin === "defaulted" && !isOptionalOut) {
2377
+ if (isPresent2 || optin === "defaulted" && !isOptionalOut) {
2378
2378
  final.value[key] = void 0;
2379
2379
  }
2380
2380
  } else {
@@ -2563,7 +2563,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
2563
2563
  continue;
2564
2564
  const id = ids[key];
2565
2565
  const k = typeof key === "symbol" ? `syms[${syms.indexOf(key)}]` : esc(key);
2566
- const isPresent = `${k} in input`;
2566
+ const isPresent2 = `${k} in input`;
2567
2567
  const schema = shape[key];
2568
2568
  const optin = schema?._zod?.optin;
2569
2569
  const isOptionalIn = optin !== void 0;
@@ -2572,7 +2572,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
2572
2572
  if (isOptionalIn && isOptionalOut) {
2573
2573
  const assign = optin === "optional" ? `${id}_present` : `${id}.value !== undefined || ${id}_present`;
2574
2574
  doc.write(`
2575
- const ${id}_present = ${isPresent};
2575
+ const ${id}_present = ${isPresent2};
2576
2576
  if (!${id}.issues.length || ${id}_present) {
2577
2577
  if (${id}.issues.length) {${prefixStr(id, k)}
2578
2578
  }
@@ -2585,7 +2585,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
2585
2585
  `);
2586
2586
  } else if (!isOptionalIn) {
2587
2587
  doc.write(`
2588
- const ${id}_present = ${isPresent};
2588
+ const ${id}_present = ${isPresent2};
2589
2589
  if (${id}.issues.length) {${prefixStr(id, k)}
2590
2590
  }
2591
2591
  if (!${id}_present && !${id}.issues.length) {
@@ -2615,7 +2615,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
2615
2615
  doc.write(`newResult[${k}] = ${id}.value;`);
2616
2616
  } else {
2617
2617
  doc.write(`
2618
- if (${id}.value !== undefined || ${isPresent}) {
2618
+ if (${id}.value !== undefined || ${isPresent2}) {
2619
2619
  newResult[${k}] = ${id}.value;
2620
2620
  }
2621
2621
  `);
@@ -6265,6 +6265,8 @@ function iacProviderOf(resourceType) {
6265
6265
  billing: !NON_BILLING_PROVIDERS.has(name)
6266
6266
  };
6267
6267
  }
6268
+ var IAC_UNSET_IN_CONFIG = "unset_in_config";
6269
+ var IAC_UNKNOWN_IN_PLAN = "unknown_in_plan";
6268
6270
  var IAC_RESOURCE_SPECS = {
6269
6271
  // ── Azure ──
6270
6272
  azurerm_linux_virtual_machine: {
@@ -6295,16 +6297,35 @@ var IAC_RESOURCE_SPECS = {
6295
6297
  instancesAttribute: "instances",
6296
6298
  os: "windows"
6297
6299
  },
6298
- // ── AWS: reconocidos y proyectados, todavía sin catálogo de precios ──
6300
+ // ── AWS ──
6301
+ // hashicorp/aws 6.x admite `region` por recurso; sin escribirla el plan trae la del provider.
6299
6302
  aws_instance: {
6300
6303
  provider: "aws",
6301
- attributes: ["instance_type"],
6302
- sizeAttribute: "instance_type"
6304
+ attributes: ["instance_type", "region"],
6305
+ sizeAttribute: "instance_type",
6306
+ regionAttribute: "region",
6307
+ regionPlannedByProvider: true
6303
6308
  },
6309
+ // Aurora (`aws_rds_cluster*`) no está: el precio de su instancia depende del `storage_type` del clúster, que es otro recurso.
6304
6310
  aws_db_instance: {
6305
6311
  provider: "aws",
6306
- attributes: ["instance_class", "engine", "multi_az", "allocated_storage"],
6307
- sizeAttribute: "instance_class"
6312
+ attributes: [
6313
+ "instance_class",
6314
+ "engine",
6315
+ "license_model",
6316
+ "multi_az",
6317
+ "allocated_storage",
6318
+ "storage_type",
6319
+ "iops",
6320
+ "storage_throughput",
6321
+ "region"
6322
+ ],
6323
+ sizeAttribute: "instance_class",
6324
+ regionAttribute: "region",
6325
+ regionPlannedByProvider: true,
6326
+ unsetReportable: ["multi_az", "storage_type", "iops", "storage_throughput"],
6327
+ unsetBlockedBy: ["snapshot_identifier", "replicate_source_db"],
6328
+ unsetBlockedByBlocks: ["restore_to_point_in_time", "s3_import"]
6308
6329
  },
6309
6330
  // ── GCP: ídem ──
6310
6331
  google_compute_instance: {
@@ -6378,7 +6399,7 @@ function aggregateEstimates(estimates) {
6378
6399
  const pricedProviders = /* @__PURE__ */ new Set();
6379
6400
  for (const estimate of estimates) {
6380
6401
  for (const item of estimate.priced) {
6381
- const key = keyOf(item);
6402
+ const key = `${keyOf(item)}#${(item.assumedDefaults ?? []).join(",")}`;
6382
6403
  const found = priced.get(key);
6383
6404
  if (!found) priced.set(key, { ...item });
6384
6405
  else {
@@ -9283,6 +9304,62 @@ function extractDefaultRegions(plan) {
9283
9304
  }
9284
9305
  return Object.keys(out).length > 0 ? out : void 0;
9285
9306
  }
9307
+ function moduleNames(moduleAddress) {
9308
+ if (!moduleAddress) return [];
9309
+ const parts = moduleAddress.replace(/\[(?:\d+|"(?:[^"\\]|\\.)*")\]/g, "").split(".");
9310
+ if (parts.length % 2 !== 0) return null;
9311
+ const names = [];
9312
+ for (let i = 0; i < parts.length; i += 2) {
9313
+ if (parts[i] !== "module" || !/^[A-Za-z0-9_-]+$/.test(parts[i + 1] ?? "")) return null;
9314
+ names.push(parts[i + 1]);
9315
+ }
9316
+ return names;
9317
+ }
9318
+ function configExpressions(plan, change) {
9319
+ return configResource(plan, change)?.expressions;
9320
+ }
9321
+ function configResource(plan, change) {
9322
+ if (!change.name) return void 0;
9323
+ const path3 = moduleNames(change.module_address);
9324
+ if (path3 === null) return void 0;
9325
+ let module = plan.configuration?.root_module;
9326
+ for (const name of path3) module = module?.module_calls?.[name]?.module;
9327
+ const address = `${change.type}.${change.name}`;
9328
+ return module?.resources?.find((r) => r.address === address && (r.mode ?? "managed") === "managed");
9329
+ }
9330
+ function knownAtPlan(expression, inRoot) {
9331
+ if ("constant_value" in expression) return true;
9332
+ const references = Array.isArray(expression.references) ? expression.references : [];
9333
+ return inRoot && references.length > 0 && references.every((r) => typeof r === "string" && r.startsWith("var."));
9334
+ }
9335
+ function hasUnknown(value) {
9336
+ if (value === true) return true;
9337
+ if (Array.isArray(value)) return value.some(hasUnknown);
9338
+ if (typeof value === "object" && value !== null) return Object.values(value).some(hasUnknown);
9339
+ return false;
9340
+ }
9341
+ var isPresent = (value) => Array.isArray(value) ? value.length > 0 : value !== null && value !== void 0 && value !== "";
9342
+ function unsetInConfig(change, expressions, names, blockedBy = [], blockedByBlocks = []) {
9343
+ const unknownSide = change.after_unknown;
9344
+ if (!names || !unknownSide || !expressions) return void 0;
9345
+ if (blockedBy.some((name) => name in expressions || isPresent(change.after?.[name]))) return void 0;
9346
+ if (blockedByBlocks.some((name) => name in expressions || isPresent(change.after?.[name]) || hasUnknown(unknownSide[name]))) return void 0;
9347
+ const unset = names.filter((name) => unknownSide[name] === true && !(name in expressions));
9348
+ return unset.length > 0 ? unset.join(",") : void 0;
9349
+ }
9350
+ function regionUntrusted(plan, change, regionAttribute) {
9351
+ if (change.change.after_unknown?.[regionAttribute] === true) return true;
9352
+ if (!isPresent(change.change.after?.[regionAttribute])) return false;
9353
+ const resource = configResource(plan, change);
9354
+ if (!resource?.expressions) return true;
9355
+ const expression = resource.expressions[regionAttribute];
9356
+ if (expression !== void 0) return !knownAtPlan(expression, (moduleNames(change.module_address) ?? ["?"]).length === 0);
9357
+ const key = resource.provider_config_key;
9358
+ const provider = key === void 0 ? void 0 : plan.configuration?.provider_config?.[key];
9359
+ if (!provider) return true;
9360
+ const providerRegion = provider.expressions?.["region"];
9361
+ return providerRegion !== void 0 && !knownAtPlan(providerRegion, provider.module_address === void 0);
9362
+ }
9286
9363
  function pickAttributes(side, unknownSide, attributeNames) {
9287
9364
  if (!side) return void 0;
9288
9365
  const attrs = {};
@@ -9290,6 +9367,7 @@ function pickAttributes(side, unknownSide, attributeNames) {
9290
9367
  if (unknownSide?.[name] === true) continue;
9291
9368
  const value = side[name];
9292
9369
  if (typeof value === "string" || typeof value === "number") attrs[name] = value;
9370
+ else if (typeof value === "boolean") attrs[name] = String(value);
9293
9371
  }
9294
9372
  return Object.keys(attrs).length > 0 ? attrs : void 0;
9295
9373
  }
@@ -9302,7 +9380,17 @@ function parseTerraformPlan(plan) {
9302
9380
  if (!action) continue;
9303
9381
  const spec = IAC_RESOURCE_SPECS[resourceChange.type];
9304
9382
  const attributesBefore = spec ? pickAttributes(resourceChange.change.before, void 0, spec.attributes) : void 0;
9305
- const attributesAfter = spec ? pickAttributes(resourceChange.change.after, resourceChange.change.after_unknown, spec.attributes) : void 0;
9383
+ const picked = spec ? pickAttributes(resourceChange.change.after, resourceChange.change.after_unknown, spec.attributes) : void 0;
9384
+ const expressions = spec?.unsetReportable || spec?.regionPlannedByProvider ? configExpressions(plan, resourceChange) : void 0;
9385
+ const creates = action === "create" || action === "replace";
9386
+ const unset = creates ? unsetInConfig(resourceChange.change, expressions, spec?.unsetReportable, spec?.unsetBlockedBy, spec?.unsetBlockedByBlocks) : void 0;
9387
+ const regionAttribute = spec?.regionPlannedByProvider ? spec.regionAttribute : void 0;
9388
+ const untrusted = picked !== void 0 && regionAttribute !== void 0 && regionUntrusted(plan, resourceChange, regionAttribute);
9389
+ let attributesAfter = unset === void 0 ? picked : { ...picked, [IAC_UNSET_IN_CONFIG]: unset };
9390
+ if (untrusted && attributesAfter) {
9391
+ const { [regionAttribute]: _dropped, ...rest } = attributesAfter;
9392
+ attributesAfter = { ...rest, [IAC_UNKNOWN_IN_PLAN]: regionAttribute };
9393
+ }
9306
9394
  const change = {
9307
9395
  type: resourceChange.type,
9308
9396
  action,
@@ -9333,6 +9421,9 @@ var REASON_TEXT = {
9333
9421
  region_not_supported: "region not in the price catalog",
9334
9422
  size_unknown: "size/SKU could not be resolved from the plan",
9335
9423
  instance_count_unknown: "instance count could not be resolved from the plan",
9424
+ attribute_unknown: "a price-relevant attribute (engine, Multi-AZ, storage\u2026) could not be resolved from the plan",
9425
+ price_unavailable: "the price catalog did not answer this time; a later run retries",
9426
+ price_lookup_limit: "too many distinct database configurations in one plan to look up in a single request",
9336
9427
  price_not_found: "no matching price found in the catalog"
9337
9428
  };
9338
9429
  var fmtUsd = (n) => `${n < 0 ? "-" : "+"}$${Math.abs(n).toFixed(2)}/mo`;
@@ -9340,7 +9431,7 @@ function fmtConverted(n, currency) {
9340
9431
  const amount = new Intl.NumberFormat("en-US", { style: "currency", currency }).format(Math.abs(n));
9341
9432
  return `\u2248 ${n < 0 ? "-" : "+"}${amount}/mo`;
9342
9433
  }
9343
- var LIST_PRICE_CAVEAT = "They also do not reflect committed-use discounts (EDP/EA/CUD) or existing reservations/Savings Plans that may already cover a resource.";
9434
+ var LIST_PRICE_CAVEAT = "They also do not reflect committed-use discounts (EDP/EA/CUD) or existing reservations/Savings Plans that may already cover a resource. Usage-based charges (data transfer, backups beyond the allocated storage, I/O requests, CPU credits), storage growth from autoscaling (`max_allocated_storage`), RDS Extended Support and paid Performance Insights retention are not included.";
9344
9435
  var reasonText = (reason) => REASON_TEXT[reason] ?? reason;
9345
9436
  function actionVerb(action) {
9346
9437
  return action === "create" ? "new" : action === "delete" ? "removed" : action;
@@ -9371,8 +9462,16 @@ function renderComment(estimate) {
9371
9462
  if (estimate.priced.length > 0) {
9372
9463
  lines2.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
9373
9464
  for (const item of estimate.priced) {
9465
+ const assumed = item.assumedDefaults?.length ? " \u2020" : "";
9374
9466
  lines2.push(
9375
- `| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${delta(item.monthlyDeltaUsd, item.monthlyDelta)} |`
9467
+ `| \`${item.type}\`${assumed} | ${actionVerb(item.action)} | ${item.count} | ${delta(item.monthlyDeltaUsd, item.monthlyDelta)} |`
9468
+ );
9469
+ }
9470
+ const assumedNames = [...new Set(estimate.priced.flatMap((i) => i.assumedDefaults ?? []))].sort();
9471
+ if (assumedNames.length > 0) {
9472
+ lines2.push(
9473
+ "",
9474
+ `\u2020 Not set in the Terraform configuration, so the AWS default was assumed for: ${assumedNames.map((n) => `\`${n}\``).join(", ")}. Set them explicitly if your deployment differs.`
9376
9475
  );
9377
9476
  }
9378
9477
  }
@@ -10649,7 +10748,7 @@ var trailGestionIncompleta = {
10649
10748
  evaluate: gestionIncompletaEvaluate
10650
10749
  };
10651
10750
 
10652
- // ../../services/security/src/collectors/internet.ts
10751
+ // ../../shared/src/internet-exposure.ts
10653
10752
  var ADMIN_PORTS2 = [22, 3389];
10654
10753
  var DATABASE_PORTS = [1433, 1521, 2483, 2484, 3306, 5432, 6379, 9200, 9300, 27017, 27018, 27019];
10655
10754
  var ETIQUETAS_INTERNET = /* @__PURE__ */ new Set(["*", "internet", "any", "/0"]);
@@ -10708,6 +10807,42 @@ function databasePortsReached(protocol, portRanges) {
10708
10807
  if (adminPortsReached(protocol, portRanges).length > 0) return [];
10709
10808
  return portsReached(DATABASE_PORTS, protocol, portRanges);
10710
10809
  }
10810
+ var TODO_INTERNET = /* @__PURE__ */ new Set(["*", "internet", "any"]);
10811
+ function sourceCoversInternet(source) {
10812
+ return TODO_INTERNET.has(source.trim().toLowerCase());
10813
+ }
10814
+ function ipFamily(source) {
10815
+ const ip = source.trim().split("/")[0] ?? "";
10816
+ if (ip.includes(":")) return "v6";
10817
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) ? "v4" : void 0;
10818
+ }
10819
+ function denySourceCovers(denySource, allowSource) {
10820
+ if (sourceCoversInternet(denySource)) return true;
10821
+ const partes = denySource.trim().split("/");
10822
+ if (partes.length !== 2 || partes[1] !== "0") return false;
10823
+ const familia = ipFamily(denySource);
10824
+ return familia !== void 0 && familia === ipFamily(allowSource);
10825
+ }
10826
+ function denyCoversEveryTarget(deny) {
10827
+ if ((deny.destinationAsgs ?? 0) > 0) return false;
10828
+ if ((deny.destinations ?? []).some((d) => !["*", "any"].includes(d.trim().toLowerCase()))) return false;
10829
+ return (deny.sourcePorts ?? []).every((p) => p.trim() === "*");
10830
+ }
10831
+ function deniedBeforeAllow(denies, allow, port) {
10832
+ const allowPriority = allow.priority;
10833
+ if (typeof allowPriority !== "number") return false;
10834
+ const abiertos = allow.sources.filter((s) => sourceOpensToInternet(s) === true);
10835
+ if (abiertos.length === 0) return false;
10836
+ const allowProtocol = protocolName(allow.protocol);
10837
+ return denies.some((deny) => {
10838
+ if (typeof deny.priority !== "number" || deny.priority >= allowPriority) return false;
10839
+ if (!denyCoversEveryTarget(deny)) return false;
10840
+ if (!abiertos.every((a) => deny.sources.some((d) => denySourceCovers(d, a)))) return false;
10841
+ const p = protocolName(deny.protocol);
10842
+ if (p !== "*" && p !== "any" && p !== "all" && p !== allowProtocol) return false;
10843
+ return deny.ports.length === 0 || deny.ports.some((rango) => cubrePuerto(rango, port));
10844
+ });
10845
+ }
10711
10846
  function cadenas2(value) {
10712
10847
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
10713
10848
  }
@@ -11397,7 +11532,9 @@ var AZURE_NSG_CHECKS = {
11397
11532
  list: "azure:nsg:list"
11398
11533
  };
11399
11534
  var AZURE_STORAGE_CHECKS = {
11400
- list: "azure:storage:list"
11535
+ list: "azure:storage:list",
11536
+ // Solo de las cuentas que permiten blobs públicos: dice a quien aprueba cerrarlo qué contenedores dejan de servirse.
11537
+ containers: "azure:storage:containers"
11401
11538
  };
11402
11539
  var AZURE_SQL_CHECKS = {
11403
11540
  list: "azure:sql:list",
@@ -11418,7 +11555,12 @@ var AZURE_IAM_CHECKS = {
11418
11555
  roleAssignments: "azure:iam:roleAssignments",
11419
11556
  // Microsoft Graph, opcional: `Directory.Read.All` de aplicación (§16.7, F4).
11420
11557
  principals: "azure:graph:directoryObjects.getByIds",
11421
- groupMembers: "azure:graph:groups.members"
11558
+ groupMembers: "azure:graph:groups.members",
11559
+ // Tipo, estado y dominio de los usuarios con rol privilegiado, y los dominios federados: también con `Directory.Read.All`.
11560
+ users: "azure:graph:users",
11561
+ domains: "azure:graph:domains",
11562
+ // Métodos de autenticación registrados: `UserAuthenticationMethod.Read.All` de aplicación, opcional.
11563
+ authMethods: "azure:graph:users.authentication.methods"
11422
11564
  };
11423
11565
  var AZURE_LOGGING_CHECKS = {
11424
11566
  diagnosticSettings: "azure:insights:diagnosticSettings"
@@ -11517,7 +11659,15 @@ function blobPublicoEvaluate(observation) {
11517
11659
  return { status: "unknown", check: AZURE_STORAGE_CHECKS.list, attribute: "allowBlobPublicAccess" };
11518
11660
  }
11519
11661
  if (!permitido) return { status: "ok" };
11520
- return { status: "finding", evidence: { allowBlobPublicAccess: true } };
11662
+ const contenedores = observation.attributes.publicContainers;
11663
+ const lista = Array.isArray(contenedores) ? contenedores.filter((c) => typeof c === "string") : void 0;
11664
+ return {
11665
+ status: "finding",
11666
+ evidence: {
11667
+ allowBlobPublicAccess: true,
11668
+ ...lista ? { publicContainers: lista, publicContainersTruncated: observation.attributes.publicContainersTruncated === true } : {}
11669
+ }
11670
+ };
11521
11671
  }
11522
11672
  var storageBlobPublico = {
11523
11673
  id: "SEC_AZURE_EXPOSURE_BLOB_PUBLICO",
@@ -11590,6 +11740,44 @@ var rolPrivilegiado = {
11590
11740
  requires: [AZURE_IAM_CHECKS.roleAssignments],
11591
11741
  evaluate: evaluate4
11592
11742
  };
11743
+ function sinMfaEvaluate(observation) {
11744
+ const role = observation.attributes.role;
11745
+ const atSubscription = observation.attributes.subscriptionScope;
11746
+ if (typeof role !== "string") return { status: "unknown", check: AZURE_IAM_CHECKS.roleAssignments, attribute: "role" };
11747
+ if (typeof atSubscription !== "boolean") return { status: "unknown", check: AZURE_IAM_CHECKS.roleAssignments, attribute: "subscriptionScope" };
11748
+ const privilegiado = role === "Owner" || role === "UserAccessAdministrator" || role === "Contributor" && atSubscription;
11749
+ if (!privilegiado || observation.attributes.principalType !== "User") return { status: "ok" };
11750
+ if (observation.attributes.principalExists === false) return { status: "ok" };
11751
+ if (observation.attributes.mfaSampled === false) {
11752
+ return { status: "unknown", check: AZURE_IAM_CHECKS.users, attribute: "mfaScope", reason: "sample_capped" };
11753
+ }
11754
+ const alcance = observation.attributes.mfaScope;
11755
+ if (typeof alcance !== "string") return { status: "unknown", check: AZURE_IAM_CHECKS.users, attribute: "mfaScope" };
11756
+ if (alcance !== "checked") return { status: "ok" };
11757
+ const segundo = observation.attributes.secondFactor;
11758
+ if (typeof segundo !== "boolean") return { status: "unknown", check: AZURE_IAM_CHECKS.authMethods, attribute: "secondFactor" };
11759
+ if (segundo) return { status: "ok" };
11760
+ return {
11761
+ status: "finding",
11762
+ evidence: {
11763
+ role,
11764
+ scope: observation.attributes.scope ?? null,
11765
+ authMethods: observation.attributes.authMethods ?? [],
11766
+ ...pick2(observation, ["principalName"])
11767
+ }
11768
+ };
11769
+ }
11770
+ var adminSinMfa = {
11771
+ id: "SEC_AZURE_IAM_ADMIN_SIN_MFA",
11772
+ scope: "resource",
11773
+ category: "security_iam",
11774
+ severity: "high",
11775
+ provider: "azure",
11776
+ kinds: ["role_assignment"],
11777
+ requires: [AZURE_IAM_CHECKS.roleAssignments, AZURE_IAM_CHECKS.domains, AZURE_IAM_CHECKS.users, AZURE_IAM_CHECKS.authMethods],
11778
+ runtimeOnly: true,
11779
+ evaluate: sinMfaEvaluate
11780
+ };
11593
11781
  function huerfanaEvaluate(observation) {
11594
11782
  if (observation.attributes.orphanApplicable === false) return { status: "ok" };
11595
11783
  const exists = observation.attributes.principalExists;
@@ -11690,6 +11878,23 @@ var vnetSinFlowLogs = {
11690
11878
  };
11691
11879
 
11692
11880
  // ../../services/security/src/rules/azure/network.ts
11881
+ function denyEntries(value) {
11882
+ if (!Array.isArray(value)) return [];
11883
+ return value.flatMap((entrada) => {
11884
+ if (typeof entrada !== "object" || entrada === null) return [];
11885
+ const regla = entrada;
11886
+ if (typeof regla.priority !== "number" || !Array.isArray(regla.destinations) || !Array.isArray(regla.sourcePorts)) return [];
11887
+ return [{
11888
+ protocol: typeof regla.protocol === "string" ? regla.protocol : "",
11889
+ sources: cadenas2(regla.sources),
11890
+ ports: cadenas2(regla.ports),
11891
+ priority: regla.priority,
11892
+ destinations: cadenas2(regla.destinations),
11893
+ destinationAsgs: typeof regla.destinationAsgs === "number" ? regla.destinationAsgs : 0,
11894
+ sourcePorts: cadenas2(regla.sourcePorts)
11895
+ }];
11896
+ });
11897
+ }
11693
11898
  var evaluate6 = (observation) => nsgAbierto(observation, adminPortsReached);
11694
11899
  function nsgAbierto(observation, reached) {
11695
11900
  const ingress = observation.attributes.ingressRules;
@@ -11699,6 +11904,7 @@ function nsgAbierto(observation, reached) {
11699
11904
  const openings = [];
11700
11905
  const puertos = /* @__PURE__ */ new Set();
11701
11906
  let indecisa = false;
11907
+ const denegaciones = denyEntries(observation.attributes.denyIngressRules);
11702
11908
  for (const entrada of ingress) {
11703
11909
  if (typeof entrada !== "object" || entrada === null) {
11704
11910
  indecisa = true;
@@ -11713,10 +11919,16 @@ function nsgAbierto(observation, reached) {
11713
11919
  }
11714
11920
  if (!abierto) continue;
11715
11921
  const protocol = typeof regla.protocol === "string" ? regla.protocol : "";
11716
- const alcanzados = reached(protocol, cadenas2(regla.ports));
11922
+ const priority = typeof regla.priority === "number" ? regla.priority : void 0;
11923
+ const alcanzados = reached(protocol, cadenas2(regla.ports)).filter((puerto) => !deniedBeforeAllow(denegaciones, { protocol, priority, sources }, puerto));
11717
11924
  if (alcanzados.length === 0) continue;
11718
11925
  for (const puerto of alcanzados) puertos.add(puerto);
11719
- openings.push({ protocol: regla.protocol, ports: alcanzados, sources: regla.sources });
11926
+ openings.push({
11927
+ protocol: regla.protocol,
11928
+ ports: alcanzados,
11929
+ sources: regla.sources,
11930
+ ...typeof regla.name === "string" ? { ruleName: regla.name } : {}
11931
+ });
11720
11932
  }
11721
11933
  if (openings.length > 0) {
11722
11934
  return { status: "finding", evidence: { ports: [...puertos].sort((a, b) => a - b), openings } };
@@ -11759,6 +11971,7 @@ var AZURE_SECURITY_RULES = [
11759
11971
  sqlSinTde,
11760
11972
  rolPrivilegiado,
11761
11973
  asignacionHuerfana,
11974
+ adminSinMfa,
11762
11975
  sinDiagnosticSettings,
11763
11976
  vnetSinFlowLogs
11764
11977
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaa-sentinel/iac-cost",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
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",