@aaa-sentinel/iac-cost 0.4.0 → 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 +31 -7
  2. package/dist/cli.js +642 -63
  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
 
@@ -175,19 +199,19 @@ value from the plan: a variable is named (`DB_PASSWORD`), its value is not read.
175
199
 
176
200
  | Cloud | Terraform resources | Rules |
177
201
  | --- | --- | --- |
178
- | AWS | `aws_security_group`, `aws_default_security_group`, `aws_security_group_rule`, `aws_vpc_security_group_ingress_rule` (+ `aws_ec2_managed_prefix_list`) | Admin ports (22/3389) open to the internet, directly or through a prefix list |
202
+ | AWS | `aws_security_group`, `aws_default_security_group`, `aws_security_group_rule`, `aws_vpc_security_group_ingress_rule` (+ `aws_ec2_managed_prefix_list`) | Admin ports (22/3389) open to the internet, directly or through a prefix list; database ports (SQL Server, Oracle, MySQL, PostgreSQL, Redis, Elasticsearch, MongoDB) open to the internet over TCP |
179
203
  | AWS | `aws_s3_bucket` + `aws_s3_bucket_public_access_block`, `aws_s3_account_public_access_block`, `aws_s3_bucket_acl`, `aws_s3_bucket_policy` | Public bucket |
180
- | AWS | `aws_db_instance`, `aws_rds_cluster_instance` (+ `aws_rds_cluster`) | Unencrypted storage |
204
+ | AWS | `aws_db_instance`, `aws_rds_cluster_instance` (+ `aws_rds_cluster`) | Unencrypted storage; publicly accessible |
181
205
  | AWS | `aws_lambda_function` + `aws_lambda_function_url`, `aws_lambda_permission` | Invocable from the internet; secret-looking environment variable names |
182
206
  | AWS | `aws_cloudtrail` | Trail stopped; management events excluded |
183
207
  | AWS | `aws_kms_key` | Key rotation off |
184
208
  | AWS | `aws_vpc`, `aws_default_vpc` + `aws_flow_log` (+ `aws_subnet`) | VPC without flow logs; flow logs that miss part of the traffic |
185
- | Azure | `azurerm_network_security_group`, `azurerm_network_security_rule` | Admin ports open to the internet |
209
+ | Azure | `azurerm_network_security_group`, `azurerm_network_security_rule` | Admin ports open to the internet; database ports open to the internet over TCP |
186
210
  | Azure | `azurerm_storage_account` | Public blobs allowed; weak TLS or HTTP |
187
211
  | Azure | `azurerm_mssql_server` + `azurerm_mssql_firewall_rule` | Public network access open to 0.0.0.0–255.255.255.255; weak TLS |
188
212
  | Azure | `azurerm_role_assignment` | Owner or User Access Administrator at any scope; Contributor at subscription scope (a Contributor at a management group or at `/` is not flagged, neither here nor in the live scan) |
189
- | Google Cloud | `google_compute_firewall` | Admin ports open to the internet |
190
- | Google Cloud | `google_sql_database_instance` | Authorized from 0.0.0.0/0; no CMEK |
213
+ | Google Cloud | `google_compute_firewall` | Admin ports open to the internet; database ports open to the internet over TCP |
214
+ | Google Cloud | `google_sql_database_instance` | Authorized from 0.0.0.0/0; no CMEK; unencrypted connections accepted (`ssl_mode`; not judged while the provider still has to compute it) |
191
215
  | Google Cloud | `google_cloudfunctions_function`, `google_cloudfunctions2_function` | Secret-looking environment variable names |
192
216
  | Google Cloud | `google_project_iam_member`, `_binding`, `_policy`; `google_service_account_key` | Primitive Owner/Editor role; user-managed key |
193
217
 
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 {
@@ -8153,6 +8174,8 @@ function sqlInstances(index) {
8153
8174
  const builder = new ObservationBuilder(index, sql, "cloud_sql_instance", "gcp").cloudId("name");
8154
8175
  const ip = ["settings", 0, "ip_configuration", 0];
8155
8176
  builder.from("ipv4Enabled", index.get(sql, ...ip, "ipv4_enabled"), Boolean, true);
8177
+ builder.from("sslMode", index.get(sql, ...ip, "ssl_mode"), String, null);
8178
+ builder.from("requireSsl", index.get(sql, ...ip, "require_ssl"), Boolean, null);
8156
8179
  const networks = index.get(sql, ...ip, "authorized_networks");
8157
8180
  if (networks.state === "unknown") builder.gap("authorizedNetworks", "known_after_apply");
8158
8181
  else {
@@ -8268,6 +8291,18 @@ var RULE_TITLES = {
8268
8291
  SEC_AWS_NETWORK_ADMIN_PREFIX_LIST: "Admin ports open to the internet through a prefix list",
8269
8292
  SEC_AWS_EXPOSURE_S3_PUBLICO: "Public S3 bucket",
8270
8293
  SEC_AWS_EXPOSURE_LAMBDA_PUBLICA: "Lambda invocable from the internet",
8294
+ SEC_AZURE_ENCRYPTION_KEYVAULT_SIN_PURGA: "Key Vault without purge protection",
8295
+ SEC_AZURE_ENCRYPTION_SQL_SIN_TDE: "SQL database without TDE encryption",
8296
+ SEC_AZURE_LOGGING_VNET_SIN_FLOW_LOGS: "VNet without flow logs",
8297
+ SEC_GCP_ENCRYPTION_KMS_SIN_ROTACION: "Cloud KMS key without 90-day rotation",
8298
+ SEC_GCP_LOGGING_VPC_SIN_FLOW_LOGS: "VPC network with subnets without flow logs",
8299
+ SEC_AWS_NETWORK_BD_ABIERTO: "Database ports open to the internet",
8300
+ SEC_AZURE_NETWORK_BD_ABIERTO: "NSG with database ports open to the internet",
8301
+ SEC_GCP_NETWORK_BD_ABIERTO: "Firewall with database ports open to the internet",
8302
+ SEC_AWS_EXPOSURE_RDS_PUBLICA: "Publicly accessible RDS instance",
8303
+ SEC_GCP_ENCRYPTION_SQL_SIN_SSL: "Cloud SQL accepts unencrypted connections",
8304
+ SEC_AWS_IAM_CLAVE_ANTIGUA: "Active access key older than 90 days",
8305
+ SEC_AWS_IAM_POLITICA_CONTRASENAS: "Weak or missing IAM password policy",
8271
8306
  SEC_AWS_ENCRYPTION_KMS_SIN_ROTACION: "KMS key without rotation",
8272
8307
  SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR: "Unencrypted RDS",
8273
8308
  SEC_AWS_LOGGING_TRAIL_PARADO: "CloudTrail stopped",
@@ -9269,6 +9304,62 @@ function extractDefaultRegions(plan) {
9269
9304
  }
9270
9305
  return Object.keys(out).length > 0 ? out : void 0;
9271
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
+ }
9272
9363
  function pickAttributes(side, unknownSide, attributeNames) {
9273
9364
  if (!side) return void 0;
9274
9365
  const attrs = {};
@@ -9276,6 +9367,7 @@ function pickAttributes(side, unknownSide, attributeNames) {
9276
9367
  if (unknownSide?.[name] === true) continue;
9277
9368
  const value = side[name];
9278
9369
  if (typeof value === "string" || typeof value === "number") attrs[name] = value;
9370
+ else if (typeof value === "boolean") attrs[name] = String(value);
9279
9371
  }
9280
9372
  return Object.keys(attrs).length > 0 ? attrs : void 0;
9281
9373
  }
@@ -9288,7 +9380,17 @@ function parseTerraformPlan(plan) {
9288
9380
  if (!action) continue;
9289
9381
  const spec = IAC_RESOURCE_SPECS[resourceChange.type];
9290
9382
  const attributesBefore = spec ? pickAttributes(resourceChange.change.before, void 0, spec.attributes) : void 0;
9291
- 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
+ }
9292
9394
  const change = {
9293
9395
  type: resourceChange.type,
9294
9396
  action,
@@ -9319,6 +9421,9 @@ var REASON_TEXT = {
9319
9421
  region_not_supported: "region not in the price catalog",
9320
9422
  size_unknown: "size/SKU could not be resolved from the plan",
9321
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",
9322
9427
  price_not_found: "no matching price found in the catalog"
9323
9428
  };
9324
9429
  var fmtUsd = (n) => `${n < 0 ? "-" : "+"}$${Math.abs(n).toFixed(2)}/mo`;
@@ -9326,7 +9431,7 @@ function fmtConverted(n, currency) {
9326
9431
  const amount = new Intl.NumberFormat("en-US", { style: "currency", currency }).format(Math.abs(n));
9327
9432
  return `\u2248 ${n < 0 ? "-" : "+"}${amount}/mo`;
9328
9433
  }
9329
- 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.";
9330
9435
  var reasonText = (reason) => REASON_TEXT[reason] ?? reason;
9331
9436
  function actionVerb(action) {
9332
9437
  return action === "create" ? "new" : action === "delete" ? "removed" : action;
@@ -9357,8 +9462,16 @@ function renderComment(estimate) {
9357
9462
  if (estimate.priced.length > 0) {
9358
9463
  lines2.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
9359
9464
  for (const item of estimate.priced) {
9465
+ const assumed = item.assumedDefaults?.length ? " \u2020" : "";
9360
9466
  lines2.push(
9361
- `| \`${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.`
9362
9475
  );
9363
9476
  }
9364
9477
  }
@@ -9540,8 +9653,6 @@ function publicVulnerable(input, provider) {
9540
9653
  }
9541
9654
 
9542
9655
  // ../../services/security/src/rules/aws/combos.ts
9543
- var DIA_MS = 24 * 60 * 60 * 1e3;
9544
- var OLD_ACCESS_KEY_DAYS = 90;
9545
9656
  function lambdaRef(resourceId, region) {
9546
9657
  const partes = resourceId.split(":");
9547
9658
  if (partes[0] === "arn" && partes[2] === "lambda" && partes[5] === "function" && partes[3] && partes[6]) {
@@ -9580,38 +9691,23 @@ var AWS_SECURITY_COMBOS = [
9580
9691
  id: "COMBO_AWS_PUBLIC_DB_UNENCRYPTED",
9581
9692
  provider: "aws",
9582
9693
  severity: "critical",
9583
- rules: ["SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR"],
9584
- detect: (input) => input.findings.filter((f) => f.ruleId === "SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR").flatMap((f) => {
9585
- const db = observationOf(input, "rds_instance", f.region, f.resourceId);
9586
- if (db?.attributes["publiclyAccessible"] !== true) return [];
9587
- return [{ region: f.region, resourceId: f.resourceId, resourceName: f.resourceName, findingIds: [f.id], evidence: { publiclyAccessible: true } }];
9588
- })
9694
+ rules: ["SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR", "SEC_AWS_EXPOSURE_RDS_PUBLICA"],
9695
+ detect: (input) => sameResource(input, "SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR", "SEC_AWS_EXPOSURE_RDS_PUBLICA").map((m) => ({ ...m, evidence: { publiclyAccessible: true } }))
9589
9696
  },
9590
9697
  {
9591
9698
  // Entra a la consola solo con contraseña y además tiene una clave de acceso vieja y activa: dos puertas sin segundo factor.
9592
9699
  id: "COMBO_AWS_USER_NO_MFA_OLD_KEY",
9593
9700
  provider: "aws",
9594
9701
  severity: "critical",
9595
- rules: ["SEC_AWS_IAM_USUARIO_SIN_MFA"],
9596
- detect: (input) => input.findings.filter((f) => f.ruleId === "SEC_AWS_IAM_USUARIO_SIN_MFA").flatMap((f) => {
9597
- const user = observationOf(input, "iam_user", f.region, f.resourceId);
9598
- const claves = Array.isArray(user?.attributes["accessKeys"]) ? user.attributes["accessKeys"] : [];
9599
- const viejas = claves.filter((k) => k["active"] === true && typeof k["createdAt"] === "string").map((k) => k["createdAt"]).filter((creada) => {
9600
- const dias = Math.floor((input.now.getTime() - Date.parse(creada)) / DIA_MS);
9601
- return Number.isFinite(dias) && dias > OLD_ACCESS_KEY_DAYS;
9602
- }).sort();
9603
- if (viejas.length === 0) return [];
9604
- return [
9605
- {
9606
- region: f.region,
9607
- resourceId: f.resourceId,
9608
- resourceName: f.resourceName,
9609
- findingIds: [f.id],
9610
- // 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.
9611
- evidence: { oldActiveKeys: viejas.length, oldestKeyCreatedAt: viejas[0] }
9612
- }
9613
- ];
9614
- })
9702
+ rules: ["SEC_AWS_IAM_USUARIO_SIN_MFA", "SEC_AWS_IAM_CLAVE_ANTIGUA"],
9703
+ detect: (input) => {
9704
+ const viejas = findingsByResource(input.findings, "SEC_AWS_IAM_CLAVE_ANTIGUA");
9705
+ return sameResource(input, "SEC_AWS_IAM_USUARIO_SIN_MFA", "SEC_AWS_IAM_CLAVE_ANTIGUA").map((m) => {
9706
+ const claves = viejas.get(`${m.region}::${m.resourceId}`)?.evidence["oldActiveKeys"];
9707
+ const lista = Array.isArray(claves) ? claves : [];
9708
+ return { ...m, evidence: { oldActiveKeys: lista.length, oldestKeyCreatedAt: lista[0]?.["createdAt"] ?? null } };
9709
+ });
9710
+ }
9615
9711
  },
9616
9712
  {
9617
9713
  // Alcanzable desde internet y con un CVE crítico o alto que ya tiene exploit público: el ataque está escrito.
@@ -10090,6 +10186,31 @@ var rdsClusterSnapshotPublico = {
10090
10186
  ],
10091
10187
  evaluate: (observation) => snapshotPublicoEvaluate(observation, RDS_CHECKS.clusterSnapshotAttributes)
10092
10188
  };
10189
+ function rdsPublicaEvaluate(observation) {
10190
+ const publica = observation.attributes.publiclyAccessible;
10191
+ if (typeof publica !== "boolean") {
10192
+ return { status: "unknown", check: RDS_CHECKS.instances, attribute: "publiclyAccessible" };
10193
+ }
10194
+ if (!publica) return { status: "ok" };
10195
+ return {
10196
+ status: "finding",
10197
+ evidence: {
10198
+ publiclyAccessible: true,
10199
+ engine: observation.attributes.engine ?? null,
10200
+ securityGroupIds: observation.attributes.securityGroupIds ?? null
10201
+ }
10202
+ };
10203
+ }
10204
+ var rdsPublica = {
10205
+ id: "SEC_AWS_EXPOSURE_RDS_PUBLICA",
10206
+ scope: "resource",
10207
+ category: "security_exposure",
10208
+ severity: "high",
10209
+ provider: "aws",
10210
+ kinds: ["rds_instance"],
10211
+ requires: [REGION_CHECKS.listRegions, RDS_CHECKS.instances],
10212
+ evaluate: rdsPublicaEvaluate
10213
+ };
10093
10214
 
10094
10215
  // ../../services/security/src/rules/aws/iam.ts
10095
10216
  function falta4(check, attribute) {
@@ -10143,6 +10264,74 @@ var usuarioSinMfa = {
10143
10264
  requires: [IAM_CHECKS.listUsers, IAM_CHECKS.loginProfile, IAM_CHECKS.mfaDevices],
10144
10265
  evaluate: usuarioSinMfaEvaluate
10145
10266
  };
10267
+ var ACCESS_KEY_MAX_AGE_DAYS = 90;
10268
+ var DIA_MS = 864e5;
10269
+ function claveAntiguaEvaluate(observation, ahora = Date.now()) {
10270
+ const claves = observation.attributes.accessKeys;
10271
+ if (!Array.isArray(claves)) return falta4(IAM_CHECKS.accessKeys, "accessKeys");
10272
+ const viejas = [];
10273
+ let indecisa = false;
10274
+ for (const entrada of claves) {
10275
+ if (typeof entrada !== "object" || entrada === null) {
10276
+ indecisa = true;
10277
+ continue;
10278
+ }
10279
+ const clave = entrada;
10280
+ if (clave.active === false) continue;
10281
+ const creada = typeof clave.createdAt === "string" ? Date.parse(clave.createdAt) : Number.NaN;
10282
+ if (clave.active !== true || Number.isNaN(creada)) {
10283
+ indecisa = true;
10284
+ continue;
10285
+ }
10286
+ if (ahora - creada > ACCESS_KEY_MAX_AGE_DAYS * DIA_MS) viejas.push({ id: clave.id ?? null, createdAt: clave.createdAt });
10287
+ }
10288
+ if (viejas.length === 0) return indecisa ? falta4(IAM_CHECKS.accessKeys, "accessKeys") : { status: "ok" };
10289
+ viejas.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
10290
+ return { status: "finding", evidence: { oldActiveKeys: viejas, maxAgeDays: ACCESS_KEY_MAX_AGE_DAYS } };
10291
+ }
10292
+ var claveAntigua = {
10293
+ id: "SEC_AWS_IAM_CLAVE_ANTIGUA",
10294
+ scope: "resource",
10295
+ category: "security_iam",
10296
+ severity: "medium",
10297
+ provider: "aws",
10298
+ kinds: ["iam_user"],
10299
+ requires: [IAM_CHECKS.listUsers, IAM_CHECKS.accessKeys],
10300
+ evaluate: (observation) => claveAntiguaEvaluate(observation)
10301
+ };
10302
+ var PASSWORD_MIN_LENGTH = 14;
10303
+ var PASSWORD_REUSE_PREVENTION = 24;
10304
+ function politicaContrasenasEvaluate(observation) {
10305
+ const usuarios = observation.attributes.userCount;
10306
+ if (typeof usuarios !== "number") return falta4(IAM_CHECKS.accountSummary, "userCount");
10307
+ if (usuarios === 0) return { status: "ok" };
10308
+ if (!("passwordPolicy" in observation.attributes)) return falta4(IAM_CHECKS.passwordPolicy, "passwordPolicy");
10309
+ const politica = observation.attributes.passwordPolicy;
10310
+ if (politica === null) {
10311
+ return { status: "finding", evidence: { passwordPolicy: null, userCount: usuarios } };
10312
+ }
10313
+ if (typeof politica !== "object" || Array.isArray(politica)) return falta4(IAM_CHECKS.passwordPolicy, "passwordPolicy");
10314
+ const p = politica;
10315
+ const longitud = typeof p.minimumLength === "number" ? p.minimumLength : null;
10316
+ const reuso = typeof p.reusePrevention === "number" ? p.reusePrevention : null;
10317
+ const corta = longitud === null || longitud < PASSWORD_MIN_LENGTH;
10318
+ const reutilizable = reuso === null || reuso < PASSWORD_REUSE_PREVENTION;
10319
+ if (!corta && !reutilizable) return { status: "ok" };
10320
+ return {
10321
+ status: "finding",
10322
+ evidence: { minimumLength: longitud, reusePrevention: reuso, userCount: usuarios }
10323
+ };
10324
+ }
10325
+ var politicaContrasenas = {
10326
+ id: "SEC_AWS_IAM_POLITICA_CONTRASENAS",
10327
+ scope: "resource",
10328
+ category: "security_iam",
10329
+ severity: "low",
10330
+ provider: "aws",
10331
+ kinds: ["iam_account"],
10332
+ requires: [IAM_CHECKS.accountSummary, IAM_CHECKS.passwordPolicy],
10333
+ evaluate: politicaContrasenasEvaluate
10334
+ };
10146
10335
  function exclusion(observation) {
10147
10336
  const value = observation.attributes.excluded;
10148
10337
  if (value === null) return { known: true, excluded: false };
@@ -10559,8 +10748,9 @@ var trailGestionIncompleta = {
10559
10748
  evaluate: gestionIncompletaEvaluate
10560
10749
  };
10561
10750
 
10562
- // ../../services/security/src/collectors/internet.ts
10751
+ // ../../shared/src/internet-exposure.ts
10563
10752
  var ADMIN_PORTS2 = [22, 3389];
10753
+ var DATABASE_PORTS = [1433, 1521, 2483, 2484, 3306, 5432, 6379, 9200, 9300, 27017, 27018, 27019];
10564
10754
  var ETIQUETAS_INTERNET = /* @__PURE__ */ new Set(["*", "internet", "any", "/0"]);
10565
10755
  function longitudPrefijo(cidr, maximo) {
10566
10756
  const partes = cidr.split("/");
@@ -10601,13 +10791,57 @@ function protocolName(protocol) {
10601
10791
  const p = protocol.trim().toLowerCase();
10602
10792
  return POR_NUMERO2[p] ?? p;
10603
10793
  }
10604
- function adminPortsReached(protocol, portRanges) {
10794
+ function portsReached(ports, protocol, portRanges) {
10605
10795
  const p = protocolName(protocol);
10606
10796
  const todos = p === "*" || p === "any" || p === "all";
10607
- if (todos) return [...ADMIN_PORTS2];
10797
+ if (todos) return [...ports];
10608
10798
  if (p !== "tcp" && p !== "udp") return [];
10609
- if (portRanges.length === 0) return [...ADMIN_PORTS2];
10610
- return ADMIN_PORTS2.filter((puerto) => portRanges.some((rango) => cubrePuerto(rango, puerto)));
10799
+ if (portRanges.length === 0) return [...ports];
10800
+ return ports.filter((puerto) => portRanges.some((rango) => cubrePuerto(rango, puerto)));
10801
+ }
10802
+ function adminPortsReached(protocol, portRanges) {
10803
+ return portsReached(ADMIN_PORTS2, protocol, portRanges);
10804
+ }
10805
+ function databasePortsReached(protocol, portRanges) {
10806
+ if (protocolName(protocol) === "udp") return [];
10807
+ if (adminPortsReached(protocol, portRanges).length > 0) return [];
10808
+ return portsReached(DATABASE_PORTS, protocol, portRanges);
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
+ });
10611
10845
  }
10612
10846
  function cadenas2(value) {
10613
10847
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
@@ -10843,6 +11077,67 @@ var adminAbiertoPorPrefixList = {
10843
11077
  requires: [REGION_CHECKS.listRegions, EC2_CHECKS.securityGroups, EC2_CHECKS.prefixListEntries],
10844
11078
  evaluate: prefixListEvaluate
10845
11079
  };
11080
+ function puertosDeBaseDeDatos(regla) {
11081
+ if (typeof regla.protocol !== "string") return void 0;
11082
+ const protocolo = protocolName(regla.protocol);
11083
+ if (protocolo !== "tcp") return [];
11084
+ const desde = regla.fromPort;
11085
+ const hasta = regla.toPort;
11086
+ if (typeof desde !== "number" || typeof hasta !== "number") return void 0;
11087
+ if (ADMIN_PORTS2.some((puerto) => desde <= puerto && puerto <= hasta)) return [];
11088
+ return DATABASE_PORTS.filter((puerto) => desde <= puerto && puerto <= hasta);
11089
+ }
11090
+ function bdAbiertaEvaluate(observation) {
11091
+ const ingress = observation.attributes.ingressRules;
11092
+ if (!Array.isArray(ingress)) {
11093
+ return { status: "unknown", check: EC2_CHECKS.securityGroups, attribute: "ingressRules" };
11094
+ }
11095
+ const aperturas = [];
11096
+ const puertos = /* @__PURE__ */ new Set();
11097
+ let indecisa = false;
11098
+ for (const entrada of ingress) {
11099
+ if (typeof entrada !== "object" || entrada === null) {
11100
+ indecisa = true;
11101
+ continue;
11102
+ }
11103
+ const regla = entrada;
11104
+ const origenes = [...cadenas2(regla.cidrs), ...cadenas2(regla.ipv6Cidrs)];
11105
+ const abierto = anySourceOpensToInternet(origenes);
11106
+ if (abierto === void 0) {
11107
+ indecisa = true;
11108
+ continue;
11109
+ }
11110
+ if (!abierto) continue;
11111
+ const alcanzados = puertosDeBaseDeDatos(regla);
11112
+ if (alcanzados === void 0) {
11113
+ indecisa = true;
11114
+ continue;
11115
+ }
11116
+ if (alcanzados.length === 0) continue;
11117
+ for (const puerto of alcanzados) puertos.add(puerto);
11118
+ aperturas.push({
11119
+ protocol: regla.protocol,
11120
+ fromPort: regla.fromPort ?? null,
11121
+ toPort: regla.toPort ?? null,
11122
+ cidr: origenes.find((cidr) => sourceOpensToInternet(cidr) === true) ?? null
11123
+ });
11124
+ }
11125
+ if (aperturas.length > 0) {
11126
+ return { status: "finding", evidence: { ports: [...puertos].sort((a, b) => a - b), openings: aperturas } };
11127
+ }
11128
+ if (indecisa) return { status: "unknown", check: EC2_CHECKS.securityGroups, attribute: "ingressRules" };
11129
+ return { status: "ok" };
11130
+ }
11131
+ var bdAbiertaAInternet = {
11132
+ id: "SEC_AWS_NETWORK_BD_ABIERTO",
11133
+ scope: "resource",
11134
+ category: "security_network",
11135
+ severity: "high",
11136
+ provider: "aws",
11137
+ kinds: ["security_group"],
11138
+ requires: [REGION_CHECKS.listRegions, EC2_CHECKS.securityGroups],
11139
+ evaluate: bdAbiertaEvaluate
11140
+ };
10846
11141
 
10847
11142
  // ../../services/security/src/rules/aws/monitoring.ts
10848
11143
  var evento = (...nombres) => nombres.map((n) => `$.eventName=${n}`);
@@ -11174,17 +11469,21 @@ var secretoEnVariableDeEntorno = {
11174
11469
  var AWS_SECURITY_RULES = [
11175
11470
  adminAbiertoAInternet,
11176
11471
  adminAbiertoPorPrefixList,
11472
+ bdAbiertaAInternet,
11177
11473
  lightsailAdminAbierto,
11178
11474
  nlbAdminAbierto,
11179
11475
  bucketPublico,
11180
11476
  ebsSnapshotPublico,
11181
11477
  rdsSnapshotPublico,
11182
11478
  rdsClusterSnapshotPublico,
11479
+ rdsPublica,
11183
11480
  lambdaPublica,
11184
11481
  kmsSinRotacion,
11185
11482
  rdsSinCifrar,
11186
11483
  rootSinMfa,
11187
11484
  usuarioSinMfa,
11485
+ claveAntigua,
11486
+ politicaContrasenas,
11188
11487
  adminEfectivo,
11189
11488
  identidadSinUso,
11190
11489
  claveSinUso,
@@ -11233,17 +11532,35 @@ var AZURE_NSG_CHECKS = {
11233
11532
  list: "azure:nsg:list"
11234
11533
  };
11235
11534
  var AZURE_STORAGE_CHECKS = {
11236
- 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"
11237
11538
  };
11238
11539
  var AZURE_SQL_CHECKS = {
11239
11540
  list: "azure:sql:list",
11240
- firewallRules: "azure:sql:firewallRules"
11541
+ firewallRules: "azure:sql:firewallRules",
11542
+ databases: "azure:sql:databases",
11543
+ transparentDataEncryption: "azure:sql:transparentDataEncryption"
11544
+ };
11545
+ var AZURE_KEYVAULT_CHECKS = {
11546
+ vaults: "azure:keyvault:vaults"
11547
+ };
11548
+ var AZURE_NETWORK_CHECKS = {
11549
+ virtualNetworks: "azure:network:virtualNetworks",
11550
+ networkWatchers: "azure:network:networkWatchers",
11551
+ flowLogs: "azure:network:flowLogs",
11552
+ networkInterfaces: "azure:network:networkInterfaces"
11241
11553
  };
11242
11554
  var AZURE_IAM_CHECKS = {
11243
11555
  roleAssignments: "azure:iam:roleAssignments",
11244
11556
  // Microsoft Graph, opcional: `Directory.Read.All` de aplicación (§16.7, F4).
11245
11557
  principals: "azure:graph:directoryObjects.getByIds",
11246
- 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"
11247
11564
  };
11248
11565
  var AZURE_LOGGING_CHECKS = {
11249
11566
  diagnosticSettings: "azure:insights:diagnosticSettings"
@@ -11296,6 +11613,44 @@ var sqlTlsDebil = {
11296
11613
  requires: [AZURE_SQL_CHECKS.list],
11297
11614
  evaluate: sqlTlsEvaluate
11298
11615
  };
11616
+ function keyVaultSinPurgaEvaluate(observation) {
11617
+ if (!("purgeProtection" in observation.attributes)) {
11618
+ return { status: "unknown", check: AZURE_KEYVAULT_CHECKS.vaults, attribute: "purgeProtection" };
11619
+ }
11620
+ if (observation.attributes.purgeProtection === true) return { status: "ok" };
11621
+ return {
11622
+ status: "finding",
11623
+ evidence: { purgeProtection: false, softDelete: observation.attributes.softDelete ?? null }
11624
+ };
11625
+ }
11626
+ var keyVaultSinPurga = {
11627
+ id: "SEC_AZURE_ENCRYPTION_KEYVAULT_SIN_PURGA",
11628
+ scope: "resource",
11629
+ category: "security_encryption",
11630
+ severity: "medium",
11631
+ provider: "azure",
11632
+ kinds: ["key_vault"],
11633
+ requires: [AZURE_KEYVAULT_CHECKS.vaults],
11634
+ evaluate: keyVaultSinPurgaEvaluate
11635
+ };
11636
+ function sqlSinTdeEvaluate(observation) {
11637
+ const estado = observation.attributes.tdeState;
11638
+ if (typeof estado !== "string") {
11639
+ return { status: "unknown", check: AZURE_SQL_CHECKS.transparentDataEncryption, attribute: "tdeState" };
11640
+ }
11641
+ if (estado.toLowerCase() === "enabled") return { status: "ok" };
11642
+ return { status: "finding", evidence: { tdeState: estado } };
11643
+ }
11644
+ var sqlSinTde = {
11645
+ id: "SEC_AZURE_ENCRYPTION_SQL_SIN_TDE",
11646
+ scope: "resource",
11647
+ category: "security_encryption",
11648
+ severity: "high",
11649
+ provider: "azure",
11650
+ kinds: ["sql_database"],
11651
+ requires: [AZURE_SQL_CHECKS.list, AZURE_SQL_CHECKS.databases, AZURE_SQL_CHECKS.transparentDataEncryption],
11652
+ evaluate: sqlSinTdeEvaluate
11653
+ };
11299
11654
 
11300
11655
  // ../../services/security/src/rules/azure/exposure.ts
11301
11656
  function blobPublicoEvaluate(observation) {
@@ -11304,7 +11659,15 @@ function blobPublicoEvaluate(observation) {
11304
11659
  return { status: "unknown", check: AZURE_STORAGE_CHECKS.list, attribute: "allowBlobPublicAccess" };
11305
11660
  }
11306
11661
  if (!permitido) return { status: "ok" };
11307
- 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
+ };
11308
11671
  }
11309
11672
  var storageBlobPublico = {
11310
11673
  id: "SEC_AZURE_EXPOSURE_BLOB_PUBLICO",
@@ -11377,6 +11740,44 @@ var rolPrivilegiado = {
11377
11740
  requires: [AZURE_IAM_CHECKS.roleAssignments],
11378
11741
  evaluate: evaluate4
11379
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
+ };
11380
11781
  function huerfanaEvaluate(observation) {
11381
11782
  if (observation.attributes.orphanApplicable === false) return { status: "ok" };
11382
11783
  const exists = observation.attributes.principalExists;
@@ -11450,9 +11851,52 @@ var sinDiagnosticSettings = {
11450
11851
  requires: [AZURE_LOGGING_CHECKS.diagnosticSettings],
11451
11852
  evaluate: evaluate5
11452
11853
  };
11854
+ function vnetSinFlowLogsEvaluate(observation) {
11855
+ const vnet = observation.attributes.vnetFlowLog;
11856
+ if (typeof vnet !== "boolean") {
11857
+ return { status: "unknown", check: AZURE_NETWORK_CHECKS.flowLogs, attribute: "vnetFlowLog" };
11858
+ }
11859
+ if (vnet) return { status: "ok" };
11860
+ const subnets = observation.attributes.subnets;
11861
+ if (!Array.isArray(subnets)) {
11862
+ return { status: "unknown", check: AZURE_NETWORK_CHECKS.networkInterfaces, attribute: "subnets" };
11863
+ }
11864
+ const sinRegistro = subnets.filter((subnet) => subnet.covered !== true && subnet.platform !== true).map((subnet) => subnet.name);
11865
+ if (sinRegistro.length === 0) return { status: "ok" };
11866
+ return { status: "finding", evidence: { unloggedSubnets: sinRegistro, subnetCount: subnets.length } };
11867
+ }
11868
+ var vnetSinFlowLogs = {
11869
+ id: "SEC_AZURE_LOGGING_VNET_SIN_FLOW_LOGS",
11870
+ scope: "resource",
11871
+ category: "security_logging",
11872
+ severity: "medium",
11873
+ provider: "azure",
11874
+ kinds: ["virtual_network"],
11875
+ // Las interfaces no van aquí: solo cuentan para subredes sin log propio, y su falta ya sale como `unknown` de esa VNet.
11876
+ requires: [AZURE_NETWORK_CHECKS.virtualNetworks, AZURE_NETWORK_CHECKS.networkWatchers, AZURE_NETWORK_CHECKS.flowLogs],
11877
+ evaluate: vnetSinFlowLogsEvaluate
11878
+ };
11453
11879
 
11454
11880
  // ../../services/security/src/rules/azure/network.ts
11455
- function evaluate6(observation) {
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
+ }
11898
+ var evaluate6 = (observation) => nsgAbierto(observation, adminPortsReached);
11899
+ function nsgAbierto(observation, reached) {
11456
11900
  const ingress = observation.attributes.ingressRules;
11457
11901
  if (!Array.isArray(ingress)) {
11458
11902
  return { status: "unknown", check: AZURE_NSG_CHECKS.list, attribute: "ingressRules" };
@@ -11460,6 +11904,7 @@ function evaluate6(observation) {
11460
11904
  const openings = [];
11461
11905
  const puertos = /* @__PURE__ */ new Set();
11462
11906
  let indecisa = false;
11907
+ const denegaciones = denyEntries(observation.attributes.denyIngressRules);
11463
11908
  for (const entrada of ingress) {
11464
11909
  if (typeof entrada !== "object" || entrada === null) {
11465
11910
  indecisa = true;
@@ -11474,10 +11919,16 @@ function evaluate6(observation) {
11474
11919
  }
11475
11920
  if (!abierto) continue;
11476
11921
  const protocol = typeof regla.protocol === "string" ? regla.protocol : "";
11477
- const alcanzados = adminPortsReached(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));
11478
11924
  if (alcanzados.length === 0) continue;
11479
11925
  for (const puerto of alcanzados) puertos.add(puerto);
11480
- 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
+ });
11481
11932
  }
11482
11933
  if (openings.length > 0) {
11483
11934
  return { status: "finding", evidence: { ports: [...puertos].sort((a, b) => a - b), openings } };
@@ -11497,17 +11948,32 @@ var nsgAdminAbierto = {
11497
11948
  requires: [AZURE_NSG_CHECKS.list],
11498
11949
  evaluate: evaluate6
11499
11950
  };
11951
+ var nsgBdAbierto = {
11952
+ id: "SEC_AZURE_NETWORK_BD_ABIERTO",
11953
+ scope: "resource",
11954
+ category: "security_network",
11955
+ severity: "high",
11956
+ provider: "azure",
11957
+ kinds: ["nsg"],
11958
+ requires: [AZURE_NSG_CHECKS.list],
11959
+ evaluate: (observation) => nsgAbierto(observation, databasePortsReached)
11960
+ };
11500
11961
 
11501
11962
  // ../../services/security/src/rules/azure/index.ts
11502
11963
  var AZURE_SECURITY_RULES = [
11503
11964
  nsgAdminAbierto,
11965
+ nsgBdAbierto,
11504
11966
  storageBlobPublico,
11505
11967
  sqlPublico,
11506
11968
  storageTlsDebil,
11507
11969
  sqlTlsDebil,
11970
+ keyVaultSinPurga,
11971
+ sqlSinTde,
11508
11972
  rolPrivilegiado,
11509
11973
  asignacionHuerfana,
11510
- sinDiagnosticSettings
11974
+ adminSinMfa,
11975
+ sinDiagnosticSettings,
11976
+ vnetSinFlowLogs
11511
11977
  ];
11512
11978
 
11513
11979
  // ../../services/security/src/rules/gcp/combos.ts
@@ -11579,7 +12045,16 @@ var GCP_COMPUTE_CHECKS = {
11579
12045
  effectiveFirewalls: "gcp:compute:networks.getEffectiveFirewalls",
11580
12046
  regions: "gcp:compute:regions",
11581
12047
  regionFirewallPolicies: "gcp:compute:regionNetworkFirewallPolicies",
11582
- sharedVpcHost: "gcp:compute:sharedVpcHostFirewalls"
12048
+ sharedVpcHost: "gcp:compute:sharedVpcHostFirewalls",
12049
+ subnetworks: "gcp:compute:subnetworks"
12050
+ };
12051
+ var GCP_NETWORK_MANAGEMENT_CHECKS = {
12052
+ vpcFlowLogsConfigs: "gcp:networkmanagement:vpcFlowLogsConfigs"
12053
+ };
12054
+ var GCP_KMS_CHECKS = {
12055
+ locations: "gcp:kms:locations",
12056
+ keyRings: "gcp:kms:keyRings",
12057
+ cryptoKeys: "gcp:kms:cryptoKeys"
11583
12058
  };
11584
12059
  var GCP_SQL_CHECKS = {
11585
12060
  instances: "gcp:sql:instances"
@@ -11649,6 +12124,63 @@ var sqlSinCmek = {
11649
12124
  requires: [GCP_SQL_CHECKS.instances],
11650
12125
  evaluate: sqlSinCmekEvaluate
11651
12126
  };
12127
+ var SOLO_CIFRADO = /* @__PURE__ */ new Set(["ENCRYPTED_ONLY", "TRUSTED_CLIENT_CERTIFICATE_REQUIRED"]);
12128
+ function sqlSinSslEvaluate(observation) {
12129
+ const modo = observation.attributes.sslMode;
12130
+ if (typeof modo === "string" && modo.length > 0) {
12131
+ if (SOLO_CIFRADO.has(modo)) return { status: "ok" };
12132
+ return { status: "finding", evidence: { sslMode: modo, ipv4Enabled: observation.attributes.ipv4Enabled ?? null } };
12133
+ }
12134
+ const requiere = observation.attributes.requireSsl;
12135
+ if (requiere === true) return { status: "ok" };
12136
+ if (!("sslMode" in observation.attributes)) {
12137
+ return { status: "unknown", check: GCP_SQL_CHECKS.instances, attribute: "sslMode" };
12138
+ }
12139
+ if (requiere !== false && requiere !== null) {
12140
+ return { status: "unknown", check: GCP_SQL_CHECKS.instances, attribute: "requireSsl" };
12141
+ }
12142
+ return { status: "finding", evidence: { sslMode: null, requireSsl: requiere, ipv4Enabled: observation.attributes.ipv4Enabled ?? null } };
12143
+ }
12144
+ var sqlSinSsl = {
12145
+ id: "SEC_GCP_ENCRYPTION_SQL_SIN_SSL",
12146
+ scope: "resource",
12147
+ category: "security_encryption",
12148
+ severity: "medium",
12149
+ provider: "gcp",
12150
+ kinds: ["cloud_sql_instance"],
12151
+ requires: [GCP_SQL_CHECKS.instances],
12152
+ evaluate: sqlSinSslEvaluate
12153
+ };
12154
+ var KMS_MAX_ROTATION_DAYS = 90;
12155
+ function segundos(periodo) {
12156
+ const m = /^(\d+(?:\.\d+)?)s$/.exec(periodo);
12157
+ return m ? Number(m[1]) : void 0;
12158
+ }
12159
+ function kmsSinRotacionEvaluate2(observation) {
12160
+ const a = observation.attributes;
12161
+ if (!("purpose" in a) || !("primaryState" in a) || !("rotationPeriod" in a)) {
12162
+ return { status: "unknown", check: GCP_KMS_CHECKS.cryptoKeys, attribute: "rotationPeriod" };
12163
+ }
12164
+ if (a.purpose !== "ENCRYPT_DECRYPT" || a.primaryState !== "ENABLED") return { status: "ok" };
12165
+ if (a.importOnly === true || typeof a.protectionLevel === "string" && a.protectionLevel.startsWith("EXTERNAL")) {
12166
+ return { status: "ok" };
12167
+ }
12168
+ if (a.rotationPeriod === null) return { status: "finding", evidence: { rotationPeriod: null } };
12169
+ const s = typeof a.rotationPeriod === "string" ? segundos(a.rotationPeriod) : void 0;
12170
+ if (s === void 0) return { status: "unknown", check: GCP_KMS_CHECKS.cryptoKeys, attribute: "rotationPeriod" };
12171
+ if (s <= KMS_MAX_ROTATION_DAYS * 86400) return { status: "ok" };
12172
+ return { status: "finding", evidence: { rotationPeriodDays: Math.round(s / 86400) } };
12173
+ }
12174
+ var kmsSinRotacion2 = {
12175
+ id: "SEC_GCP_ENCRYPTION_KMS_SIN_ROTACION",
12176
+ scope: "resource",
12177
+ category: "security_encryption",
12178
+ severity: "medium",
12179
+ provider: "gcp",
12180
+ kinds: ["kms_key"],
12181
+ requires: [GCP_KMS_CHECKS.locations, GCP_KMS_CHECKS.keyRings, GCP_KMS_CHECKS.cryptoKeys],
12182
+ evaluate: kmsSinRotacionEvaluate2
12183
+ };
11652
12184
 
11653
12185
  // ../../services/security/src/rules/gcp/exposure.ts
11654
12186
  function evaluate7(observation) {
@@ -11884,9 +12416,42 @@ var sinExportSink = {
11884
12416
  requires: [GCP_LOGGING_CHECKS.sinks],
11885
12417
  evaluate: evaluate8
11886
12418
  };
12419
+ function vpcSinFlowLogsEvaluate(observation) {
12420
+ const subnets = observation.attributes.subnets;
12421
+ if (!Array.isArray(subnets)) {
12422
+ return { status: "unknown", check: GCP_COMPUTE_CHECKS.subnetworks, attribute: "subnets" };
12423
+ }
12424
+ const lista = subnets;
12425
+ const sinRegistro = lista.filter((subnet) => subnet.covered === false);
12426
+ if (sinRegistro.length > 0) {
12427
+ return {
12428
+ status: "finding",
12429
+ evidence: { unloggedSubnets: sinRegistro.map((subnet) => `${String(subnet.region)}/${String(subnet.name)}`), subnetCount: lista.length }
12430
+ };
12431
+ }
12432
+ if (lista.some((subnet) => subnet.covered !== true)) {
12433
+ return { status: "unknown", check: GCP_NETWORK_MANAGEMENT_CHECKS.vpcFlowLogsConfigs, attribute: "subnets" };
12434
+ }
12435
+ if (observation.attributes.subnetsComplete !== true) {
12436
+ return { status: "unknown", check: GCP_COMPUTE_CHECKS.subnetworks, attribute: "subnets" };
12437
+ }
12438
+ return { status: "ok" };
12439
+ }
12440
+ var vpcSinFlowLogs = {
12441
+ id: "SEC_GCP_LOGGING_VPC_SIN_FLOW_LOGS",
12442
+ scope: "resource",
12443
+ category: "security_logging",
12444
+ severity: "medium",
12445
+ provider: "gcp",
12446
+ kinds: ["vpc_network"],
12447
+ // Las configs de Network Management no van aquí: una red con `logConfig` en cada subred ya está bien sin ellas.
12448
+ requires: [GCP_COMPUTE_CHECKS.subnetworks],
12449
+ evaluate: vpcSinFlowLogsEvaluate
12450
+ };
11887
12451
 
11888
12452
  // ../../services/security/src/rules/gcp/network.ts
11889
- function evaluate9(observation) {
12453
+ var evaluate9 = (observation) => firewallAbierto(observation, adminPortsReached);
12454
+ function firewallAbierto(observation, reached) {
11890
12455
  const direction = observation.attributes.direction;
11891
12456
  const disabled = observation.attributes.disabled;
11892
12457
  const sources = observation.attributes.sourceRanges;
@@ -11905,7 +12470,7 @@ function evaluate9(observation) {
11905
12470
  if (typeof entry !== "object" || entry === null) continue;
11906
12471
  const rule = entry;
11907
12472
  const protocol = typeof rule.protocol === "string" ? rule.protocol : "";
11908
- for (const puerto of adminPortsReached(protocol, cadenas2(rule.ports))) puertos.add(puerto);
12473
+ for (const puerto of reached(protocol, cadenas2(rule.ports))) puertos.add(puerto);
11909
12474
  }
11910
12475
  if (puertos.size === 0) return { status: "ok" };
11911
12476
  return {
@@ -12065,6 +12630,16 @@ var firewallAdminAbierto = {
12065
12630
  requires: [GCP_COMPUTE_CHECKS.firewalls],
12066
12631
  evaluate: evaluate9
12067
12632
  };
12633
+ var firewallBdAbierto = {
12634
+ id: "SEC_GCP_NETWORK_BD_ABIERTO",
12635
+ scope: "resource",
12636
+ category: "security_network",
12637
+ severity: "high",
12638
+ provider: "gcp",
12639
+ kinds: ["firewall"],
12640
+ requires: [GCP_COMPUTE_CHECKS.firewalls],
12641
+ evaluate: (observation) => firewallAbierto(observation, databasePortsReached)
12642
+ };
12068
12643
 
12069
12644
  // ../../services/security/src/rules/gcp/secrets.ts
12070
12645
  var SOSPECHOSAS2 = ["PASSWORD", "PASSWD", "SECRET", "TOKEN", "APIKEY", "API_KEY", "PRIVATE_KEY", "CREDENTIAL"];
@@ -12099,11 +12674,14 @@ var secretoEnFunction = {
12099
12674
  // ../../services/security/src/rules/gcp/index.ts
12100
12675
  var GCP_SECURITY_RULES = [
12101
12676
  firewallAdminAbierto,
12677
+ firewallBdAbierto,
12102
12678
  politicaAdminAbierta,
12103
12679
  sqlPublico2,
12104
12680
  gcsPublico,
12105
12681
  discoSinCmek,
12106
12682
  sqlSinCmek,
12683
+ sqlSinSsl,
12684
+ kmsSinRotacion2,
12107
12685
  secretoEnFunction,
12108
12686
  rolPrimitivo,
12109
12687
  claveSaUserManaged,
@@ -12111,7 +12689,8 @@ var GCP_SECURITY_RULES = [
12111
12689
  claveSaSinUso,
12112
12690
  permisosSinUso2,
12113
12691
  auditIncompleto,
12114
- sinExportSink
12692
+ sinExportSink,
12693
+ vpcSinFlowLogs
12115
12694
  ];
12116
12695
 
12117
12696
  // src/security/evaluate.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaa-sentinel/iac-cost",
3
- "version": "0.4.0",
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",