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