@aaa-sentinel/iac-cost 0.4.0 → 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 +5 -5
  2. package/dist/cli.js +406 -40
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -175,19 +175,19 @@ value from the plan: a variable is named (`DB_PASSWORD`), its value is not read.
175
175
 
176
176
  | Cloud | Terraform resources | Rules |
177
177
  | --- | --- | --- |
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 |
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; database ports (SQL Server, Oracle, MySQL, PostgreSQL, Redis, Elasticsearch, MongoDB) open to the internet over TCP |
179
179
  | 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 |
180
+ | AWS | `aws_db_instance`, `aws_rds_cluster_instance` (+ `aws_rds_cluster`) | Unencrypted storage; publicly accessible |
181
181
  | AWS | `aws_lambda_function` + `aws_lambda_function_url`, `aws_lambda_permission` | Invocable from the internet; secret-looking environment variable names |
182
182
  | AWS | `aws_cloudtrail` | Trail stopped; management events excluded |
183
183
  | AWS | `aws_kms_key` | Key rotation off |
184
184
  | 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 |
185
+ | Azure | `azurerm_network_security_group`, `azurerm_network_security_rule` | Admin ports open to the internet; database ports open to the internet over TCP |
186
186
  | Azure | `azurerm_storage_account` | Public blobs allowed; weak TLS or HTTP |
187
187
  | Azure | `azurerm_mssql_server` + `azurerm_mssql_firewall_rule` | Public network access open to 0.0.0.0–255.255.255.255; weak TLS |
188
188
  | 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 |
189
+ | Google Cloud | `google_compute_firewall` | Admin ports open to the internet; database ports open to the internet over TCP |
190
+ | 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
191
  | Google Cloud | `google_cloudfunctions_function`, `google_cloudfunctions2_function` | Secret-looking environment variable names |
192
192
  | Google Cloud | `google_project_iam_member`, `_binding`, `_policy`; `google_service_account_key` | Primitive Owner/Editor role; user-managed key |
193
193
 
package/dist/cli.js CHANGED
@@ -8153,6 +8153,8 @@ function sqlInstances(index) {
8153
8153
  const builder = new ObservationBuilder(index, sql, "cloud_sql_instance", "gcp").cloudId("name");
8154
8154
  const ip = ["settings", 0, "ip_configuration", 0];
8155
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);
8156
8158
  const networks = index.get(sql, ...ip, "authorized_networks");
8157
8159
  if (networks.state === "unknown") builder.gap("authorizedNetworks", "known_after_apply");
8158
8160
  else {
@@ -8268,6 +8270,18 @@ var RULE_TITLES = {
8268
8270
  SEC_AWS_NETWORK_ADMIN_PREFIX_LIST: "Admin ports open to the internet through a prefix list",
8269
8271
  SEC_AWS_EXPOSURE_S3_PUBLICO: "Public S3 bucket",
8270
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",
8271
8285
  SEC_AWS_ENCRYPTION_KMS_SIN_ROTACION: "KMS key without rotation",
8272
8286
  SEC_AWS_ENCRYPTION_RDS_SIN_CIFRAR: "Unencrypted RDS",
8273
8287
  SEC_AWS_LOGGING_TRAIL_PARADO: "CloudTrail stopped",
@@ -9540,8 +9554,6 @@ function publicVulnerable(input, provider) {
9540
9554
  }
9541
9555
 
9542
9556
  // ../../services/security/src/rules/aws/combos.ts
9543
- var DIA_MS = 24 * 60 * 60 * 1e3;
9544
- var OLD_ACCESS_KEY_DAYS = 90;
9545
9557
  function lambdaRef(resourceId, region) {
9546
9558
  const partes = resourceId.split(":");
9547
9559
  if (partes[0] === "arn" && partes[2] === "lambda" && partes[5] === "function" && partes[3] && partes[6]) {
@@ -9580,38 +9592,23 @@ var AWS_SECURITY_COMBOS = [
9580
9592
  id: "COMBO_AWS_PUBLIC_DB_UNENCRYPTED",
9581
9593
  provider: "aws",
9582
9594
  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
- })
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 } }))
9589
9597
  },
9590
9598
  {
9591
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.
9592
9600
  id: "COMBO_AWS_USER_NO_MFA_OLD_KEY",
9593
9601
  provider: "aws",
9594
9602
  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
- })
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
+ }
9615
9612
  },
9616
9613
  {
9617
9614
  // Alcanzable desde internet y con un CVE crítico o alto que ya tiene exploit público: el ataque está escrito.
@@ -10090,6 +10087,31 @@ var rdsClusterSnapshotPublico = {
10090
10087
  ],
10091
10088
  evaluate: (observation) => snapshotPublicoEvaluate(observation, RDS_CHECKS.clusterSnapshotAttributes)
10092
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
+ };
10093
10115
 
10094
10116
  // ../../services/security/src/rules/aws/iam.ts
10095
10117
  function falta4(check, attribute) {
@@ -10143,6 +10165,74 @@ var usuarioSinMfa = {
10143
10165
  requires: [IAM_CHECKS.listUsers, IAM_CHECKS.loginProfile, IAM_CHECKS.mfaDevices],
10144
10166
  evaluate: usuarioSinMfaEvaluate
10145
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
+ };
10146
10236
  function exclusion(observation) {
10147
10237
  const value = observation.attributes.excluded;
10148
10238
  if (value === null) return { known: true, excluded: false };
@@ -10561,6 +10651,7 @@ var trailGestionIncompleta = {
10561
10651
 
10562
10652
  // ../../services/security/src/collectors/internet.ts
10563
10653
  var ADMIN_PORTS2 = [22, 3389];
10654
+ var DATABASE_PORTS = [1433, 1521, 2483, 2484, 3306, 5432, 6379, 9200, 9300, 27017, 27018, 27019];
10564
10655
  var ETIQUETAS_INTERNET = /* @__PURE__ */ new Set(["*", "internet", "any", "/0"]);
10565
10656
  function longitudPrefijo(cidr, maximo) {
10566
10657
  const partes = cidr.split("/");
@@ -10601,13 +10692,21 @@ function protocolName(protocol) {
10601
10692
  const p = protocol.trim().toLowerCase();
10602
10693
  return POR_NUMERO2[p] ?? p;
10603
10694
  }
10604
- function adminPortsReached(protocol, portRanges) {
10695
+ function portsReached(ports, protocol, portRanges) {
10605
10696
  const p = protocolName(protocol);
10606
10697
  const todos = p === "*" || p === "any" || p === "all";
10607
- if (todos) return [...ADMIN_PORTS2];
10698
+ if (todos) return [...ports];
10608
10699
  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)));
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);
10611
10710
  }
10612
10711
  function cadenas2(value) {
10613
10712
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
@@ -10843,6 +10942,67 @@ var adminAbiertoPorPrefixList = {
10843
10942
  requires: [REGION_CHECKS.listRegions, EC2_CHECKS.securityGroups, EC2_CHECKS.prefixListEntries],
10844
10943
  evaluate: prefixListEvaluate
10845
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
+ };
10846
11006
 
10847
11007
  // ../../services/security/src/rules/aws/monitoring.ts
10848
11008
  var evento = (...nombres) => nombres.map((n) => `$.eventName=${n}`);
@@ -11174,17 +11334,21 @@ var secretoEnVariableDeEntorno = {
11174
11334
  var AWS_SECURITY_RULES = [
11175
11335
  adminAbiertoAInternet,
11176
11336
  adminAbiertoPorPrefixList,
11337
+ bdAbiertaAInternet,
11177
11338
  lightsailAdminAbierto,
11178
11339
  nlbAdminAbierto,
11179
11340
  bucketPublico,
11180
11341
  ebsSnapshotPublico,
11181
11342
  rdsSnapshotPublico,
11182
11343
  rdsClusterSnapshotPublico,
11344
+ rdsPublica,
11183
11345
  lambdaPublica,
11184
11346
  kmsSinRotacion,
11185
11347
  rdsSinCifrar,
11186
11348
  rootSinMfa,
11187
11349
  usuarioSinMfa,
11350
+ claveAntigua,
11351
+ politicaContrasenas,
11188
11352
  adminEfectivo,
11189
11353
  identidadSinUso,
11190
11354
  claveSinUso,
@@ -11237,7 +11401,18 @@ var AZURE_STORAGE_CHECKS = {
11237
11401
  };
11238
11402
  var AZURE_SQL_CHECKS = {
11239
11403
  list: "azure:sql:list",
11240
- 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"
11241
11416
  };
11242
11417
  var AZURE_IAM_CHECKS = {
11243
11418
  roleAssignments: "azure:iam:roleAssignments",
@@ -11296,6 +11471,44 @@ var sqlTlsDebil = {
11296
11471
  requires: [AZURE_SQL_CHECKS.list],
11297
11472
  evaluate: sqlTlsEvaluate
11298
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
+ };
11299
11512
 
11300
11513
  // ../../services/security/src/rules/azure/exposure.ts
11301
11514
  function blobPublicoEvaluate(observation) {
@@ -11450,9 +11663,35 @@ var sinDiagnosticSettings = {
11450
11663
  requires: [AZURE_LOGGING_CHECKS.diagnosticSettings],
11451
11664
  evaluate: evaluate5
11452
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
+ };
11453
11691
 
11454
11692
  // ../../services/security/src/rules/azure/network.ts
11455
- function evaluate6(observation) {
11693
+ var evaluate6 = (observation) => nsgAbierto(observation, adminPortsReached);
11694
+ function nsgAbierto(observation, reached) {
11456
11695
  const ingress = observation.attributes.ingressRules;
11457
11696
  if (!Array.isArray(ingress)) {
11458
11697
  return { status: "unknown", check: AZURE_NSG_CHECKS.list, attribute: "ingressRules" };
@@ -11474,7 +11713,7 @@ function evaluate6(observation) {
11474
11713
  }
11475
11714
  if (!abierto) continue;
11476
11715
  const protocol = typeof regla.protocol === "string" ? regla.protocol : "";
11477
- const alcanzados = adminPortsReached(protocol, cadenas2(regla.ports));
11716
+ const alcanzados = reached(protocol, cadenas2(regla.ports));
11478
11717
  if (alcanzados.length === 0) continue;
11479
11718
  for (const puerto of alcanzados) puertos.add(puerto);
11480
11719
  openings.push({ protocol: regla.protocol, ports: alcanzados, sources: regla.sources });
@@ -11497,17 +11736,31 @@ var nsgAdminAbierto = {
11497
11736
  requires: [AZURE_NSG_CHECKS.list],
11498
11737
  evaluate: evaluate6
11499
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
+ };
11500
11749
 
11501
11750
  // ../../services/security/src/rules/azure/index.ts
11502
11751
  var AZURE_SECURITY_RULES = [
11503
11752
  nsgAdminAbierto,
11753
+ nsgBdAbierto,
11504
11754
  storageBlobPublico,
11505
11755
  sqlPublico,
11506
11756
  storageTlsDebil,
11507
11757
  sqlTlsDebil,
11758
+ keyVaultSinPurga,
11759
+ sqlSinTde,
11508
11760
  rolPrivilegiado,
11509
11761
  asignacionHuerfana,
11510
- sinDiagnosticSettings
11762
+ sinDiagnosticSettings,
11763
+ vnetSinFlowLogs
11511
11764
  ];
11512
11765
 
11513
11766
  // ../../services/security/src/rules/gcp/combos.ts
@@ -11579,7 +11832,16 @@ var GCP_COMPUTE_CHECKS = {
11579
11832
  effectiveFirewalls: "gcp:compute:networks.getEffectiveFirewalls",
11580
11833
  regions: "gcp:compute:regions",
11581
11834
  regionFirewallPolicies: "gcp:compute:regionNetworkFirewallPolicies",
11582
- 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"
11583
11845
  };
11584
11846
  var GCP_SQL_CHECKS = {
11585
11847
  instances: "gcp:sql:instances"
@@ -11649,6 +11911,63 @@ var sqlSinCmek = {
11649
11911
  requires: [GCP_SQL_CHECKS.instances],
11650
11912
  evaluate: sqlSinCmekEvaluate
11651
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
+ };
11652
11971
 
11653
11972
  // ../../services/security/src/rules/gcp/exposure.ts
11654
11973
  function evaluate7(observation) {
@@ -11884,9 +12203,42 @@ var sinExportSink = {
11884
12203
  requires: [GCP_LOGGING_CHECKS.sinks],
11885
12204
  evaluate: evaluate8
11886
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
+ };
11887
12238
 
11888
12239
  // ../../services/security/src/rules/gcp/network.ts
11889
- function evaluate9(observation) {
12240
+ var evaluate9 = (observation) => firewallAbierto(observation, adminPortsReached);
12241
+ function firewallAbierto(observation, reached) {
11890
12242
  const direction = observation.attributes.direction;
11891
12243
  const disabled = observation.attributes.disabled;
11892
12244
  const sources = observation.attributes.sourceRanges;
@@ -11905,7 +12257,7 @@ function evaluate9(observation) {
11905
12257
  if (typeof entry !== "object" || entry === null) continue;
11906
12258
  const rule = entry;
11907
12259
  const protocol = typeof rule.protocol === "string" ? rule.protocol : "";
11908
- 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);
11909
12261
  }
11910
12262
  if (puertos.size === 0) return { status: "ok" };
11911
12263
  return {
@@ -12065,6 +12417,16 @@ var firewallAdminAbierto = {
12065
12417
  requires: [GCP_COMPUTE_CHECKS.firewalls],
12066
12418
  evaluate: evaluate9
12067
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
+ };
12068
12430
 
12069
12431
  // ../../services/security/src/rules/gcp/secrets.ts
12070
12432
  var SOSPECHOSAS2 = ["PASSWORD", "PASSWD", "SECRET", "TOKEN", "APIKEY", "API_KEY", "PRIVATE_KEY", "CREDENTIAL"];
@@ -12099,11 +12461,14 @@ var secretoEnFunction = {
12099
12461
  // ../../services/security/src/rules/gcp/index.ts
12100
12462
  var GCP_SECURITY_RULES = [
12101
12463
  firewallAdminAbierto,
12464
+ firewallBdAbierto,
12102
12465
  politicaAdminAbierta,
12103
12466
  sqlPublico2,
12104
12467
  gcsPublico,
12105
12468
  discoSinCmek,
12106
12469
  sqlSinCmek,
12470
+ sqlSinSsl,
12471
+ kmsSinRotacion2,
12107
12472
  secretoEnFunction,
12108
12473
  rolPrimitivo,
12109
12474
  claveSaUserManaged,
@@ -12111,7 +12476,8 @@ var GCP_SECURITY_RULES = [
12111
12476
  claveSaSinUso,
12112
12477
  permisosSinUso2,
12113
12478
  auditIncompleto,
12114
- sinExportSink
12479
+ sinExportSink,
12480
+ vpcSinFlowLogs
12115
12481
  ];
12116
12482
 
12117
12483
  // 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.4.1",
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",