@aaa-sentinel/iac-cost 0.3.0 → 0.4.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 +66 -2
  2. package/dist/cli.js +372 -86
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -7,6 +7,13 @@ internet is caught in review instead of after the deploy. With `iac-cost fix`, t
7
7
  your last scan on resources this Terraform manages come back to you as **pull requests**, not as
8
8
  console changes (see [Fixes as pull requests](#fixes-as-pull-requests)).
9
9
 
10
+ > **New in 0.4.0:** the `posture` subcommand prints how the security posture of an account changed
11
+ > over 30, 90 or 365 days, with an optional gate on new findings (see
12
+ > [Posture over time](#posture-over-time)). Nothing changes for `fix` or the existing command.
13
+
14
+ > **Changed in 0.3.1:** the pull requests of `fix` pass `terraform fmt -check`: an added attribute is
15
+ > aligned with its group (also around heredocs and after removing a block), as `terraform fmt` does.
16
+
10
17
  > **New in 0.3.0:** the `fix` subcommand. Nothing changes for the existing command: same output,
11
18
  > same exit codes.
12
19
 
@@ -456,8 +463,10 @@ the reason, and the app shows it that way:
456
463
  | `rule_not_automated` | No deterministic code fix for that rule |
457
464
 
458
465
  The rest of the file is kept byte for byte, comments and line endings included (a removed block takes
459
- the comment lines right above it, up to the first blank line); run `terraform fmt`
460
- in review if the alignment of `=` matters to you. The pull request says what changed and why;
466
+ the comment lines right above it, up to the first blank line). An added attribute is aligned like
467
+ `terraform fmt` does: if its group of consecutive one-line attributes was already aligned, the whole
468
+ group is realigned to the longest name, so the pull request passes `terraform fmt -check`; if the
469
+ group was not formatted, only the new line is written as `name = value`. The pull request says what changed and why;
461
470
  review its `terraform plan` before merging. The finding closes after `terraform apply` and the next scan.
462
471
 
463
472
  | Option | Meaning |
@@ -538,6 +547,61 @@ pipelines:
538
547
 
539
548
  The commit is created through the Bitbucket API (`POST /src`), so the clone needs no push access.
540
549
 
550
+ ## Posture over time
551
+
552
+ `iac-cost posture` reads `GET /v1/security/posture-history` and prints, for one account and a
553
+ period of 30, 90 or 365 days:
554
+
555
+ - the Security Score from the first to the last day with a scan (the last scan of each day), or
556
+ why it is **not comparable** (the rule catalog or what could be evaluated changed in between);
557
+ - distinct findings **resolved** (gone in a scan that fully evaluated their rule) and **new**
558
+ (from rules that were already fully evaluated before);
559
+ - findings that cannot be compared (their rule was not fully evaluated in the previous scan, or
560
+ they come from an external tool) and findings gone without confirmation;
561
+ - the mean time to fix critical and high findings.
562
+
563
+ ```bash
564
+ export IAC_SECURITY_CLIENT_ID=… # OAuth2 app with the security:read scope
565
+ export IAC_SECURITY_CLIENT_SECRET=…
566
+ npx --yes @aaa-sentinel/iac-cost posture --account acc_01j… --days 90
567
+ ```
568
+
569
+ | Option | Default | |
570
+ | --- | --- | --- |
571
+ | `--account <id>` | `IAC_SECURITY_ACCOUNT_ID` | AAA Sentinel account id |
572
+ | `--days <30\|90\|365>` | `90` | Period |
573
+ | `--format <markdown\|text\|json>` | `markdown` | `json` prints the API response as is |
574
+ | `--fail-on-new <severity>` | — | Exit 1 when new findings at this severity or worse appeared in the period |
575
+
576
+ Exit codes: `1` only when `--fail-on-new` trips; `2` for a usage error (an option without a value,
577
+ a stray argument), missing credentials or, with the gate, a 4xx from the API (wrong scope or
578
+ account); `0` otherwise, also when the API does not answer (a warning explains why). Findings that
579
+ cannot be compared with the previous scan do not count for the gate.
580
+
581
+ A weekly job that writes the summary to the run page:
582
+
583
+ ```yaml
584
+ name: Security posture
585
+ on:
586
+ schedule:
587
+ - cron: '0 7 * * 1'
588
+ workflow_dispatch:
589
+
590
+ jobs:
591
+ posture:
592
+ runs-on: ubuntu-latest
593
+ steps:
594
+ - uses: actions/setup-node@v4
595
+ with:
596
+ node-version: 22
597
+ - name: Posture over the last 90 days
598
+ env:
599
+ IAC_SECURITY_CLIENT_ID: ${{ secrets.IAC_SECURITY_CLIENT_ID }}
600
+ IAC_SECURITY_CLIENT_SECRET: ${{ secrets.IAC_SECURITY_CLIENT_SECRET }}
601
+ run: |
602
+ npx --yes @aaa-sentinel/iac-cost posture --account ${{ vars.AAA_SENTINEL_ACCOUNT_ID }} --days 90 --fail-on-new critical >> "$GITHUB_STEP_SUMMARY"
603
+ ```
604
+
541
605
  ## Why there is no comment
542
606
 
543
607
  | What you see | Why |
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ var FORMATS = ["markdown", "text", "json"];
14
14
  var SEVERITIES = ["critical", "high", "medium", "low"];
15
15
  var USAGE = `Usage: iac-cost [plan.json] [options]
16
16
  iac-cost fix [plan.json] --account <accountId> [options] (see iac-cost fix --help)
17
+ iac-cost posture --account <accountId> [options] (see iac-cost posture --help)
17
18
 
18
19
  Reads \`terraform show -json\` output from the file or from stdin.
19
20
 
@@ -1724,9 +1725,9 @@ var Doc = class {
1724
1725
  return;
1725
1726
  }
1726
1727
  const content = arg;
1727
- const lines = content.split("\n").filter((x) => x);
1728
- const minIndent = Math.min(...lines.map((x) => x.length - x.trimStart().length));
1729
- const dedented = lines.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x);
1728
+ const lines2 = content.split("\n").filter((x) => x);
1729
+ const minIndent = Math.min(...lines2.map((x) => x.length - x.trimStart().length));
1730
+ const dedented = lines2.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x);
1730
1731
  for (const line of dedented) {
1731
1732
  this.content.push(line);
1732
1733
  }
@@ -6409,8 +6410,8 @@ function aggregateEstimates(estimates) {
6409
6410
  const billable = pricedResources + unpricedResources;
6410
6411
  const ratio = billable === 0 ? 1 : pricedResources / billable;
6411
6412
  const sufficientForTotal = ratio >= IAC_MIN_COVERAGE_RATIO;
6412
- const total = [...priced.values()].reduce((acc, i) => acc + i.monthlyDeltaUsd, 0);
6413
- const totalMonthlyDeltaUsd = sufficientForTotal ? round2(total) : null;
6413
+ const total2 = [...priced.values()].reduce((acc, i) => acc + i.monthlyDeltaUsd, 0);
6414
+ const totalMonthlyDeltaUsd = sufficientForTotal ? round2(total2) : null;
6414
6415
  const first = estimates[0]?.displayCurrency;
6415
6416
  const display = first && estimates.every((e) => e.displayCurrency?.code === first.code && e.displayCurrency.rate === first.rate) ? first : null;
6416
6417
  for (const item of priced.values()) {
@@ -7100,8 +7101,68 @@ function removeListItems(text, attribute, list, remove) {
7100
7101
  const body2 = keep.map((item) => `${itemIndent}${text.slice(item.start, item.end)},${eol}`).join("");
7101
7102
  return { start: attribute.valueStart, end: attribute.valueEnd, text: `[${eol}${body2}${closeIndent}]` };
7102
7103
  }
7104
+ function singleLine(text, item) {
7105
+ if (item.kind !== "attribute") return false;
7106
+ return !text.slice(item.start, item.valueEnd).includes("\n") || text.startsWith("<<", item.valueStart);
7107
+ }
7108
+ function adjacent(text, previous, next) {
7109
+ const gap = text.slice(previous.valueEnd, next.start);
7110
+ const eol = gap.indexOf("\n");
7111
+ if (eol === -1 || gap.indexOf("\n", eol + 1) !== -1) return false;
7112
+ const tail = gap.slice(0, eol).trim();
7113
+ const comment = tail === "" || tail.startsWith("#") || tail.startsWith("//") || tail.startsWith("/*") && tail.endsWith("*/");
7114
+ return comment && gap.slice(eol + 1).trim() === "";
7115
+ }
7116
+ function alignmentGroups(text, block) {
7117
+ const groups = [];
7118
+ let current = [];
7119
+ for (const item of blockBody(text, block)) {
7120
+ const last = current[current.length - 1];
7121
+ if (singleLine(text, item) && last && adjacent(text, last, item)) {
7122
+ current.push(item);
7123
+ continue;
7124
+ }
7125
+ if (current.length > 0) groups.push(current);
7126
+ current = singleLine(text, item) ? [item] : [];
7127
+ }
7128
+ if (current.length > 0) groups.push(current);
7129
+ return groups;
7130
+ }
7131
+ function equalsSpan(text, attribute) {
7132
+ return { start: attribute.start + attribute.name.length, end: attribute.valueStart };
7133
+ }
7134
+ function fmtAligned(text, group) {
7135
+ const width = Math.max(...group.map((attribute) => attribute.name.length));
7136
+ return group.every((attribute) => {
7137
+ const span = equalsSpan(text, attribute);
7138
+ return text.slice(span.start, span.end) === `${" ".repeat(width - attribute.name.length + 1)}= `;
7139
+ });
7140
+ }
7141
+ function alignAttributes(text, block, names, original) {
7142
+ const edits = [];
7143
+ const before = original ? alignmentGroups(original.text, original.block) : null;
7144
+ for (const group of alignmentGroups(text, block)) {
7145
+ const targets = group.filter((attribute) => names.includes(attribute.name));
7146
+ if (targets.length === 0) continue;
7147
+ let aligned;
7148
+ if (before && original) {
7149
+ const contributing = new Set(group.flatMap((attribute) => before.filter((old) => old.some((a) => a.name === attribute.name))));
7150
+ aligned = [...contributing].every((old) => fmtAligned(original.text, old));
7151
+ } else {
7152
+ const others = group.filter((attribute) => !names.includes(attribute.name));
7153
+ aligned = others.length === 0 || fmtAligned(text, others);
7154
+ }
7155
+ const width = Math.max(...group.map((attribute) => attribute.name.length));
7156
+ for (const attribute of aligned ? group : targets) {
7157
+ const wanted = `${" ".repeat((aligned ? width : attribute.name.length) - attribute.name.length + 1)}= `;
7158
+ const span = equalsSpan(text, attribute);
7159
+ if (text.slice(span.start, span.end) !== wanted) edits.push({ ...span, text: wanted });
7160
+ }
7161
+ }
7162
+ return edits;
7163
+ }
7103
7164
  function applyEdits(text, edits) {
7104
- const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
7165
+ const sorted = edits.map((edit2, i) => ({ edit: edit2, i })).sort((a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.i - a.i).map(({ edit: edit2 }) => edit2);
7105
7166
  let out = text;
7106
7167
  let limit = Number.POSITIVE_INFINITY;
7107
7168
  for (const edit2 of sorted) {
@@ -8225,6 +8286,7 @@ var RULE_TITLES = {
8225
8286
  SEC_AZURE_LOGGING_SIN_DIAGNOSTICO: "Subscription does not export Activity Log",
8226
8287
  SEC_GCP_NETWORK_ADMIN_ABIERTO: "Firewall with admin ports open to the internet",
8227
8288
  SEC_GCP_EXPOSURE_SQL_PUBLICO: "Cloud SQL authorized from 0.0.0.0/0",
8289
+ SEC_GCP_EXPOSURE_GCS_PUBLICO: "Public Cloud Storage bucket (allUsers or allAuthenticatedUsers)",
8228
8290
  SEC_GCP_ENCRYPTION_SQL_SIN_CMEK: "Cloud SQL without CMEK",
8229
8291
  SEC_GCP_SECRETS_FUNCTION_ENV: "Secret in a Cloud Function environment variable",
8230
8292
  SEC_GCP_IAM_ROL_PRIMITIVO: "Primitive Owner or Editor role on the project",
@@ -8244,6 +8306,19 @@ var ruleTitle = (id) => RULE_TITLES[id] ?? id;
8244
8306
  var comboTitle = (id) => COMBO_TITLES[id] ?? id;
8245
8307
 
8246
8308
  // src/fix/catalog.ts
8309
+ function finalizePatch(patch) {
8310
+ const find = (source, target) => parseBody(source, 0, source.length).find(
8311
+ (item) => item.kind === "block" && item.type === "resource" && item.labels[0] === target.type && item.labels[1] === target.name
8312
+ );
8313
+ let text = applyEdits(patch.original, patch.edits);
8314
+ for (const target of patch.align) {
8315
+ const block = find(text, target);
8316
+ if (!block) continue;
8317
+ const before = find(patch.original, target);
8318
+ text = applyEdits(text, alignAttributes(text, block, target.attributes, before ? { text: patch.original, block: before } : void 0));
8319
+ }
8320
+ return text;
8321
+ }
8247
8322
  var OPEN_CIDRS = /* @__PURE__ */ new Set(["0.0.0.0/0", "::/0"]);
8248
8323
  var ADMIN_PORTS = [22, 3389];
8249
8324
  var Suggest = class extends Error {
@@ -8295,10 +8370,16 @@ var PatchBuilder = class {
8295
8370
  byFile = /* @__PURE__ */ new Map();
8296
8371
  changes = [];
8297
8372
  first = null;
8298
- add(where, edit2, attribute, change, lifecycleName = attribute) {
8373
+ add(where, edit2, attribute, change, lifecycleName = attribute, alignName = null) {
8299
8374
  if (lifecycleName !== null && ignoresChanges(where.text, where.block, lifecycleName)) throw new Suggest("ignore_changes", lifecycleName);
8300
- const patch = this.byFile.get(where.absolutePath) ?? { file: where.file, absolutePath: where.absolutePath, original: where.text, edits: [] };
8375
+ const patch = this.byFile.get(where.absolutePath) ?? { file: where.file, absolutePath: where.absolutePath, original: where.text, edits: [], align: [] };
8301
8376
  patch.edits.push(edit2);
8377
+ const [type, name] = where.block.labels;
8378
+ if (alignName !== null && type && name) {
8379
+ const target = patch.align.find((a) => a.type === type && a.name === name);
8380
+ if (target) target.attributes.push(alignName);
8381
+ else patch.align.push({ type, name, attributes: [alignName] });
8382
+ }
8302
8383
  this.byFile.set(where.absolutePath, patch);
8303
8384
  this.changes.push(change);
8304
8385
  this.first ??= { file: where.file, attribute };
@@ -8327,7 +8408,10 @@ function setLiteral(builder, where, items, name, wanted, ok, insertWhenAbsent =
8327
8408
  where,
8328
8409
  edit(() => setAttribute(where.text, where.block, name, wanted), name),
8329
8410
  name,
8330
- before === null ? `add \`${name} = ${wanted}\`` : `\`${name}\`: \`${before}\` \u2192 \`${wanted}\``
8411
+ // Solo se alinea lo añadido: cambiar un valor no mueve el `=` de nadie.
8412
+ before === null ? `add \`${name} = ${wanted}\`` : `\`${name}\`: \`${before}\` \u2192 \`${wanted}\``,
8413
+ name,
8414
+ before === null ? name : null
8331
8415
  );
8332
8416
  return "changed";
8333
8417
  }
@@ -8439,21 +8523,25 @@ function awsAdminOpen(ctx, builder) {
8439
8523
  if (blocksOf(items, "dynamic").some((block) => block.labels[0] === "ingress")) throw new Suggest("dynamic_block", "ingress");
8440
8524
  if (attributeOf(items, "ingress")) throw new Suggest("value_from_expression", "ingress");
8441
8525
  let judged = 0;
8442
- const apply = (target, block, blockItems, shape, label) => {
8526
+ const apply = (target, block, blockItems, shape, label, alignName = null) => {
8443
8527
  const verdict = judgeRule(target.text, block, blockItems, shape, label);
8444
8528
  if (!verdict.open) return false;
8445
8529
  judged += 1;
8446
8530
  const lifecycleName = shape === INLINE_RULE ? "ingress" : verdict.removesBlock ? null : verdict.attribute;
8447
- verdict.edits.forEach((fn, i) => builder.add(target, edit(fn, verdict.attribute), verdict.attribute, verdict.changes[i] ?? verdict.changes[0] ?? "", lifecycleName));
8531
+ verdict.edits.forEach(
8532
+ (fn, i) => builder.add(target, edit(fn, verdict.attribute), verdict.attribute, verdict.changes[i] ?? verdict.changes[0] ?? "", lifecycleName, verdict.removesBlock ? alignName : null)
8533
+ );
8448
8534
  return verdict.removesBlock;
8449
8535
  };
8450
8536
  const inline = blocksOf(items, "ingress");
8451
8537
  let inlineRemoved = 0;
8452
8538
  for (const ingress of inline) {
8453
- if (apply(where, ingress, body(where.text, ingress), INLINE_RULE, `the \`ingress\` block of \`${anchor2.address}\``)) inlineRemoved += 1;
8539
+ const before = items[items.indexOf(ingress) - 1];
8540
+ const alignName = before?.kind === "attribute" ? before.name : null;
8541
+ if (apply(where, ingress, body(where.text, ingress), INLINE_RULE, `the \`ingress\` block of \`${anchor2.address}\``, alignName)) inlineRemoved += 1;
8454
8542
  }
8455
8543
  if (inline.length > 0 && inlineRemoved === inline.length) {
8456
- builder.add(where, edit(() => setAttribute(where.text, where.block, "ingress", "[]"), "ingress"), "ingress", "set `ingress = []` so Terraform removes the rule instead of leaving it unmanaged");
8544
+ builder.add(where, edit(() => setAttribute(where.text, where.block, "ingress", "[]"), "ingress"), "ingress", "set `ingress = []` so Terraform removes the rule instead of leaving it unmanaged", "ingress", "ingress");
8457
8545
  }
8458
8546
  const parents = ["aws_security_group", "aws_default_security_group"];
8459
8547
  const children = [
@@ -8693,7 +8781,7 @@ function carry(findingId, fix2, state) {
8693
8781
  }
8694
8782
  async function runFix(options, deps) {
8695
8783
  const warnings = [];
8696
- const lines = [];
8784
+ const lines2 = [];
8697
8785
  const reports = [];
8698
8786
  const { host } = deps;
8699
8787
  let input;
@@ -8701,7 +8789,7 @@ async function runFix(options, deps) {
8701
8789
  input = await deps.api.load();
8702
8790
  } catch (error2) {
8703
8791
  warnings.push(`could not read the open findings, nothing to fix: ${error2.message}`);
8704
- return { stdout: render(options, lines, deps.accountId, null), warnings, lines, reported: 0 };
8792
+ return { stdout: render(options, lines2, deps.accountId, null), warnings, lines: lines2, reported: 0 };
8705
8793
  }
8706
8794
  if (input.truncated) warnings.push("the account has more open findings than the API returns (500 most severe): the rest are left for a later run");
8707
8795
  let publicRepo = false;
@@ -8732,7 +8820,7 @@ async function runFix(options, deps) {
8732
8820
  }
8733
8821
  if (state !== "merged") {
8734
8822
  reports.push(carry(finding.findingId, existing, state));
8735
- lines.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: existing.address, outcome: state, pullRequest: existing.pullRequest.url });
8823
+ lines2.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: existing.address, outcome: state, pullRequest: existing.pullRequest.url });
8736
8824
  continue;
8737
8825
  }
8738
8826
  existing.state = "merged";
@@ -8743,7 +8831,7 @@ async function runFix(options, deps) {
8743
8831
  if (outcome.kind === "patch") {
8744
8832
  try {
8745
8833
  for (const patch2 of outcome.patches) {
8746
- const patched = applyEdits(patch2.original, patch2.edits);
8834
+ const patched = finalizePatch(patch2);
8747
8835
  parseBody(patched, 0, patched.length);
8748
8836
  }
8749
8837
  } catch (error2) {
@@ -8754,7 +8842,7 @@ async function runFix(options, deps) {
8754
8842
  const merged = existing && sameRepo && existing.state === "merged" && existing.pullRequest ? existing : null;
8755
8843
  if (merged && outcome.kind !== "patch") {
8756
8844
  reports.push(carry(finding.findingId, merged, "merged"));
8757
- lines.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: merged.address, outcome: "merged", ...merged.pullRequest ? { pullRequest: merged.pullRequest.url } : {} });
8845
+ lines2.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: merged.address, outcome: "merged", ...merged.pullRequest ? { pullRequest: merged.pullRequest.url } : {} });
8758
8846
  continue;
8759
8847
  }
8760
8848
  const base = { findingId: finding.findingId, ruleId: finding.ruleId, address: outcome.address };
@@ -8767,18 +8855,18 @@ async function runFix(options, deps) {
8767
8855
  ...outcome.file !== void 0 ? { file: outcome.file } : {},
8768
8856
  ...outcome.attribute !== void 0 ? { attribute: outcome.attribute } : {}
8769
8857
  });
8770
- lines.push({ ...base, outcome: "suggested", reason: outcome.reason, ...outcome.file !== void 0 ? { file: outcome.file } : {} });
8858
+ lines2.push({ ...base, outcome: "suggested", reason: outcome.reason, ...outcome.file !== void 0 ? { file: outcome.file } : {} });
8771
8859
  continue;
8772
8860
  }
8773
8861
  if (outcome.kind === "fixed_in_code") {
8774
8862
  reports.push({ findingId: finding.findingId, state: "pending", reason: "fixed_in_code", address: outcome.address, file: outcome.file, attribute: outcome.attribute });
8775
- lines.push({ ...base, outcome: "pending", reason: "fixed_in_code", file: outcome.file });
8863
+ lines2.push({ ...base, outcome: "pending", reason: "fixed_in_code", file: outcome.file });
8776
8864
  continue;
8777
8865
  }
8778
8866
  const patch = outcome;
8779
8867
  const pending = (reason) => {
8780
8868
  reports.push({ findingId: finding.findingId, state: "pending", reason, address: patch.address, file: patch.file, attribute: patch.attribute });
8781
- lines.push({ ...base, outcome: "pending", reason, file: patch.file, changes: patch.changes });
8869
+ lines2.push({ ...base, outcome: "pending", reason, file: patch.file, changes: patch.changes });
8782
8870
  };
8783
8871
  if (options.dryRun || !host) {
8784
8872
  pending("dry_run");
@@ -8786,7 +8874,7 @@ async function runFix(options, deps) {
8786
8874
  }
8787
8875
  if (host.unsupported) {
8788
8876
  reports.push({ findingId: finding.findingId, state: "suggested", reason: "unsupported_host", address: patch.address, file: patch.file, attribute: patch.attribute });
8789
- lines.push({ ...base, outcome: "suggested", reason: "unsupported_host", file: patch.file, changes: patch.changes });
8877
+ lines2.push({ ...base, outcome: "suggested", reason: "unsupported_host", file: patch.file, changes: patch.changes });
8790
8878
  continue;
8791
8879
  }
8792
8880
  if (publicRepo) {
@@ -8810,7 +8898,7 @@ async function runFix(options, deps) {
8810
8898
  repository: host.repository,
8811
8899
  pullRequest: { url: pr.url, number: pr.number }
8812
8900
  });
8813
- lines.push({ ...base, outcome: state, file: patch.file, pullRequest: pr.url, changes: patch.changes });
8901
+ lines2.push({ ...base, outcome: state, file: patch.file, pullRequest: pr.url, changes: patch.changes });
8814
8902
  };
8815
8903
  try {
8816
8904
  if (await host.branchExists(branch)) {
@@ -8834,7 +8922,7 @@ async function runFix(options, deps) {
8834
8922
  bases.push(atBase);
8835
8923
  }
8836
8924
  const files = patch.patches.map((file, i) => {
8837
- const patched = applyEdits(file.original, file.edits);
8925
+ const patched = finalizePatch(file);
8838
8926
  const atBase = bases[i] ?? file.original;
8839
8927
  return { path: file.file, content: atBase === file.original ? patched : likeBase(patched, atBase) };
8840
8928
  });
@@ -8860,7 +8948,7 @@ async function runFix(options, deps) {
8860
8948
  warnings.push(`could not record the fixes in AAA Sentinel: ${error2.message}`);
8861
8949
  }
8862
8950
  }
8863
- return { stdout: render(options, lines, deps.accountId, input.scanAt), warnings, lines, reported };
8951
+ return { stdout: render(options, lines2, deps.accountId, input.scanAt), warnings, lines: lines2, reported };
8864
8952
  }
8865
8953
  var SkipFinding = class extends Error {
8866
8954
  };
@@ -8879,11 +8967,11 @@ var OUTCOME_TEXT = {
8879
8967
  pending: "pending",
8880
8968
  suggested: "change suggested, no pull request"
8881
8969
  };
8882
- function render(options, lines, accountId, scanAt) {
8883
- if (options.format === "json") return JSON.stringify({ formatVersion: "1", accountId, scanAt, dryRun: options.dryRun, fixes: lines }, null, 2);
8884
- if (lines.length === 0) return `No open finding of account ${accountId} matches a resource of this plan.`;
8970
+ function render(options, lines2, accountId, scanAt) {
8971
+ if (options.format === "json") return JSON.stringify({ formatVersion: "1", accountId, scanAt, dryRun: options.dryRun, fixes: lines2 }, null, 2);
8972
+ if (lines2.length === 0) return `No open finding of account ${accountId} matches a resource of this plan.`;
8885
8973
  const out = [`Security fixes for account ${accountId}${options.dryRun ? " (dry run: nothing opened or recorded)" : ""}`];
8886
- for (const line of lines) {
8974
+ for (const line of lines2) {
8887
8975
  const detail = [line.reason, line.file, line.pullRequest].filter(Boolean).join(", ");
8888
8976
  out.push(` ${line.address} \u2014 ${title(line.ruleId)}: ${OUTCOME_TEXT[line.outcome]}${detail ? ` (${detail})` : ""}`);
8889
8977
  for (const change of line.changes ?? []) out.push(` ${change}`);
@@ -8992,6 +9080,168 @@ ${FIX_USAGE}`);
8992
9080
  return { stdout: result.stdout, warnings: [...warnings, ...result.warnings], exitCode: 0 };
8993
9081
  }
8994
9082
 
9083
+ // src/posture/posture.ts
9084
+ import { parseArgs as parseArgs3 } from "node:util";
9085
+ var POSTURE_USAGE = `Usage: iac-cost posture --account <accountId> [options]
9086
+
9087
+ Prints how the security posture of an AAA Sentinel account changed over a
9088
+ period: Score from the first to the last day with a scan, distinct findings
9089
+ resolved and new, mean time to fix, and catalog or coverage changes that make
9090
+ the Score not comparable.
9091
+
9092
+ Options:
9093
+ --account <accountId> AAA Sentinel account id (acc_\u2026), or IAC_SECURITY_ACCOUNT_ID
9094
+ --days <30|90|365> Period (default: 90)
9095
+ --format <markdown|text|json>
9096
+ Output format (default: markdown)
9097
+ --fail-on-new <critical|high|medium|low>
9098
+ Exit 1 when new findings at this severity or worse
9099
+ appeared in the period (findings that cannot be
9100
+ compared with the previous scan do not count).
9101
+ With the gate, a 4xx from the API (scope, account)
9102
+ exits 2; our outage still exits 0.
9103
+ -h, --help Show this help
9104
+
9105
+ Environment:
9106
+ IAC_SECURITY_CLIENT_ID / IAC_SECURITY_CLIENT_SECRET OAuth2 app with the security:read scope`;
9107
+ var SEVERITIES2 = ["critical", "high", "medium", "low"];
9108
+ var DAYS = [30, 90, 365];
9109
+ var FORMATS2 = ["markdown", "text", "json"];
9110
+ var TIMEOUT_MS3 = 35e3;
9111
+ var APP_URL = "https://app.aaa-sentinel.com";
9112
+ var total = (c) => SEVERITIES2.reduce((n, s) => n + (c[s] ?? 0), 0);
9113
+ var bySeverity = (c) => SEVERITIES2.filter((s) => (c[s] ?? 0) > 0).map((s) => `${c[s]} ${s}`).join(", ");
9114
+ var REASONS = {
9115
+ catalog_changed: "the rule catalog changed",
9116
+ coverage_changed: "what could be evaluated changed",
9117
+ not_compared: "a scan could not be compared with the previous one",
9118
+ scored_changed: "the number of scoring rules changed",
9119
+ partial: "one end was read incompletely"
9120
+ };
9121
+ function scoreLine(summary) {
9122
+ const from = summary.from?.score;
9123
+ const to = summary.to?.score;
9124
+ if (from === null || from === void 0 || to === null || to === void 0) return "no comparable Score in the period";
9125
+ if (!summary.comparable) {
9126
+ const why = summary.notComparableReasons.map((r) => REASONS[r] ?? r).join("; ");
9127
+ return `${from} \u2192 ${to}, not comparable (${why})`;
9128
+ }
9129
+ if (to > from) return `up from ${from} to ${to}`;
9130
+ if (to < from) return `down from ${from} to ${to}`;
9131
+ return `steady at ${to}`;
9132
+ }
9133
+ function lines(history) {
9134
+ const s = history.summary;
9135
+ const fix2 = ["critical", "high"].filter((sev) => s.meanFixDays[sev] !== null).map((sev) => `${sev} ${s.meanFixDays[sev]} d`);
9136
+ const out = [
9137
+ ["Score", scoreLine(s)],
9138
+ ["Resolved", `${total(s.resolved)}${total(s.resolved) ? ` (${bySeverity(s.resolved)})` : ""}`],
9139
+ ["New", `${total(s.opened)}${total(s.opened) ? ` (${bySeverity(s.opened)})` : ""}`]
9140
+ ];
9141
+ if (total(s.surfaced)) out.push(["Not comparable", `${total(s.surfaced)} new findings whose rule was not fully evaluated before`]);
9142
+ if (total(s.unverified)) out.push(["Unverified", `${total(s.unverified)} gone without confirmation`]);
9143
+ out.push(["Mean time to fix", fix2.length ? fix2.join(", ") : "no critical or high finding fixed with a known date"]);
9144
+ out.push(["Scans", String(s.scans)]);
9145
+ return out;
9146
+ }
9147
+ function renderPosture(history, format) {
9148
+ const enlace = `${APP_URL}/posture?accountId=${encodeURIComponent(history.accountId)}`;
9149
+ const notas = [];
9150
+ if (history.points.length < 2) notas.push("The trend appears once the period has scans on at least two different days.");
9151
+ if (history.acceptedRisks) notas.push("The account has accepted risks: each day keeps the Score computed at the time.");
9152
+ if (history.summary.notCompared) notas.push(`${history.summary.notCompared} scan(s) could not be compared with the previous one.`);
9153
+ if (history.summary.changesTruncated) notas.push("A scan had more changes than are stored: the figures are a minimum.");
9154
+ if (history.truncated) notas.push("The period has more scans than fit: only the most recent ones are counted.");
9155
+ const titulo = `Security posture, last ${history.days} days`;
9156
+ if (format === "text") {
9157
+ return [titulo, ...lines(history).map(([k, v]) => ` ${k}: ${v}`), ...notas.map((n) => ` ${n}`), ` ${enlace}`].join("\n");
9158
+ }
9159
+ return [
9160
+ `### ${titulo}`,
9161
+ "",
9162
+ "| | |",
9163
+ "|---|---|",
9164
+ ...lines(history).map(([k, v]) => `| ${k} | ${v} |`),
9165
+ "",
9166
+ ...notas.map((n) => `> ${n}`),
9167
+ ...notas.length ? [""] : [],
9168
+ `[Open in AAA Sentinel](${enlace})`
9169
+ ].join("\n");
9170
+ }
9171
+ function newAtOrAbove(summary, severity) {
9172
+ return SEVERITIES2.slice(0, SEVERITIES2.indexOf(severity) + 1).reduce((n, s) => n + (summary.opened[s] ?? 0), 0);
9173
+ }
9174
+ async function fetchPostureHistory(config2) {
9175
+ const fetchImpl = config2.fetchImpl ?? fetch;
9176
+ const token = await fetchToken(config2, "security:read");
9177
+ const url = `${config2.baseUrl}/v1/security/posture-history?accountId=${encodeURIComponent(config2.accountId)}&days=${config2.days}`;
9178
+ const response = await fetchImpl(url, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(TIMEOUT_MS3) });
9179
+ if (!response.ok) throw new Error(`posture_history_http_${response.status}`);
9180
+ return await response.json();
9181
+ }
9182
+ async function runPostureCli(argv, io) {
9183
+ const warnings = [];
9184
+ const fail = (message) => ({ stdout: null, warnings: [...warnings, message], exitCode: 2 });
9185
+ const { values, positionals } = parseArgs3({
9186
+ args: [...argv],
9187
+ strict: false,
9188
+ allowPositionals: true,
9189
+ options: {
9190
+ account: { type: "string" },
9191
+ days: { type: "string" },
9192
+ format: { type: "string" },
9193
+ "fail-on-new": { type: "string" },
9194
+ help: { type: "boolean", short: "h" }
9195
+ }
9196
+ });
9197
+ const known = /* @__PURE__ */ new Set(["account", "days", "format", "fail-on-new", "help", "h"]);
9198
+ for (const name of Object.keys(values)) {
9199
+ if (known.has(name)) continue;
9200
+ if (/^fail[-_]?on/i.test(name)) return fail(`unknown option --${name}: the gate is spelled --fail-on-new`);
9201
+ warnings.push(`unknown option --${name} ignored`);
9202
+ }
9203
+ if (values.help === true || values.h === true) return { stdout: POSTURE_USAGE, warnings, exitCode: 0 };
9204
+ for (const name of ["account", "days", "format", "fail-on-new"]) {
9205
+ const v = values[name];
9206
+ if (v === true || typeof v === "string" && v.startsWith("-")) return fail(`--${name} needs a value`);
9207
+ }
9208
+ if (positionals.length > 0) return fail(`unexpected argument ${positionals.join(" ")}`);
9209
+ const account = (typeof values.account === "string" ? values.account : io.env["IAC_SECURITY_ACCOUNT_ID"])?.trim();
9210
+ if (!account) return fail(`posture needs --account <accountId>
9211
+
9212
+ ${POSTURE_USAGE}`);
9213
+ const days = values.days === void 0 ? 90 : Number(values.days);
9214
+ if (!DAYS.includes(days)) return fail("--days must be 30, 90 or 365");
9215
+ const format = values.format === void 0 ? "markdown" : values.format;
9216
+ if (typeof format !== "string" || !FORMATS2.includes(format)) return fail(`--format must be one of ${FORMATS2.join(", ")}`);
9217
+ let gate = null;
9218
+ if (values["fail-on-new"] !== void 0) {
9219
+ const raw = typeof values["fail-on-new"] === "string" ? values["fail-on-new"].trim().toLowerCase() : "";
9220
+ if (!SEVERITIES2.includes(raw)) return fail(`--fail-on-new must be one of ${SEVERITIES2.join(", ")}`);
9221
+ gate = raw;
9222
+ }
9223
+ const baseUrl = resolveBaseUrl(io.env["IAC_COST_BASE_URL"] ?? "https://api.aaa-sentinel.com");
9224
+ if (baseUrl === null) return fail("IAC_COST_BASE_URL must be an https URL");
9225
+ const clientId = io.env["IAC_SECURITY_CLIENT_ID"];
9226
+ const clientSecret = io.env["IAC_SECURITY_CLIENT_SECRET"];
9227
+ if (!clientId || !clientSecret) return fail("IAC_SECURITY_CLIENT_ID/IAC_SECURITY_CLIENT_SECRET are not set: create an app with the security:read scope");
9228
+ let history;
9229
+ try {
9230
+ history = await fetchPostureHistory({ baseUrl, clientId, clientSecret, accountId: account, days, ...io.fetchImpl ? { fetchImpl: io.fetchImpl } : {} });
9231
+ } catch (error2) {
9232
+ const mensaje = error2.message;
9233
+ if (gate && /_http_4(00|01|03|04)$/.test(mensaje)) return fail(`could not read the posture history (${mensaje}): check the app scope (security:read) and the account id`);
9234
+ return { stdout: null, warnings: [...warnings, `could not read the posture history, skipping: ${mensaje}`], exitCode: 0 };
9235
+ }
9236
+ if (history.access !== "full") {
9237
+ return { stdout: null, warnings: [...warnings, "the account does not have the security add-on: no posture history to show"], exitCode: 0 };
9238
+ }
9239
+ const nuevos = gate ? newAtOrAbove(history.summary, gate) : 0;
9240
+ const stdout = format === "json" ? JSON.stringify(history, null, 2) : renderPosture(history, format);
9241
+ if (gate && nuevos > 0) warnings.push(`${nuevos} new finding(s) at ${gate} or worse in the last ${days} days (--fail-on-new ${gate})`);
9242
+ return { stdout, warnings, exitCode: gate && nuevos > 0 ? 1 : 0 };
9243
+ }
9244
+
8995
9245
  // src/terraform-plan.ts
8996
9246
  function actionOf2(actions) {
8997
9247
  if (actions.includes("create") && actions.includes("delete")) return "replace";
@@ -9083,51 +9333,51 @@ function actionVerb(action) {
9083
9333
  }
9084
9334
  function renderComment(estimate) {
9085
9335
  const { coverage } = estimate;
9086
- const lines = [COMMENT_MARKER, "### \u{1F4B2} Infrastructure cost estimate", ""];
9087
- lines.push(
9336
+ const lines2 = [COMMENT_MARKER, "### \u{1F4B2} Infrastructure cost estimate", ""];
9337
+ lines2.push(
9088
9338
  `**Coverage: ${coverage.pricedResources} of ${coverage.pricedResources + coverage.unpricedResources} billable resources priced** (${coverage.freeResources} more have no cost by design).`
9089
9339
  );
9090
- lines.push("");
9340
+ lines2.push("");
9091
9341
  const display = estimate.displayCurrency?.approximate ? estimate.displayCurrency : null;
9092
9342
  const delta = (usd, converted) => display && typeof converted === "number" ? fmtConverted(converted, display.code) : fmtUsd(usd);
9093
9343
  if (estimate.totalMonthlyDeltaUsd !== null) {
9094
- const total = delta(estimate.totalMonthlyDeltaUsd, estimate.totalMonthlyDelta);
9095
- lines.push(
9096
- display ? `**Estimated change: ${total}** (list price, ${fmtUsd(estimate.totalMonthlyDeltaUsd)} in USD)` : `**Estimated change: ${total}** (list price, USD)`
9344
+ const total2 = delta(estimate.totalMonthlyDeltaUsd, estimate.totalMonthlyDelta);
9345
+ lines2.push(
9346
+ display ? `**Estimated change: ${total2}** (list price, ${fmtUsd(estimate.totalMonthlyDeltaUsd)} in USD)` : `**Estimated change: ${total2}** (list price, USD)`
9097
9347
  );
9098
9348
  } else {
9099
- lines.push(
9349
+ lines2.push(
9100
9350
  `_Coverage is below the threshold needed to publish a total \u2014 see the breakdown below instead of a number that wouldn't hold up._`
9101
9351
  );
9102
9352
  }
9103
- lines.push("");
9104
- lines.push(
9353
+ lines2.push("");
9354
+ lines2.push(
9105
9355
  display ? `> These are **list prices**, not what you actually pay. Cloud providers publish them in USD; the amounts shown are **approximate**, converted to ${display.code} at the ECB reference rate${display.rateDate ? ` of ${display.rateDate}` : ""} (1 USD = ${display.rate} ${display.code}). ` + LIST_PRICE_CAVEAT : `> These are **list prices in USD**, not what you actually pay. Cloud providers set their list prices in USD, so showing this in another billing currency would be an estimate on top of an estimate. ${LIST_PRICE_CAVEAT}`
9106
9356
  );
9107
9357
  if (estimate.priced.length > 0) {
9108
- lines.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
9358
+ lines2.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
9109
9359
  for (const item of estimate.priced) {
9110
- lines.push(
9360
+ lines2.push(
9111
9361
  `| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${delta(item.monthlyDeltaUsd, item.monthlyDelta)} |`
9112
9362
  );
9113
9363
  }
9114
9364
  }
9115
9365
  if (estimate.unpriced.length > 0) {
9116
- lines.push("", "<details><summary>Not priced (" + coverage.unpricedResources + " resources)</summary>", "");
9117
- lines.push("| Resource | Change | Qty | Why |", "|---|---|---|---|");
9366
+ lines2.push("", "<details><summary>Not priced (" + coverage.unpricedResources + " resources)</summary>", "");
9367
+ lines2.push("| Resource | Change | Qty | Why |", "|---|---|---|---|");
9118
9368
  for (const item of estimate.unpriced) {
9119
- lines.push(`| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${reasonText(item.reason)} |`);
9369
+ lines2.push(`| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${reasonText(item.reason)} |`);
9120
9370
  }
9121
- lines.push("", "</details>");
9371
+ lines2.push("", "</details>");
9122
9372
  }
9123
9373
  const truncated = estimate.catalogs.filter((c) => c.truncated);
9124
9374
  if (truncated.length > 0) {
9125
- lines.push(
9375
+ lines2.push(
9126
9376
  "",
9127
9377
  `\u26A0\uFE0F The price catalog for ${truncated.map((c) => c.region).join(", ")} was truncated by pagination: some prices may be missing.`
9128
9378
  );
9129
9379
  }
9130
- return lines.join("\n");
9380
+ return lines2.join("\n");
9131
9381
  }
9132
9382
 
9133
9383
  // src/security/drift.ts
@@ -11264,7 +11514,7 @@ var AZURE_SECURITY_RULES = [
11264
11514
  var minus = (s) => typeof s === "string" ? s.toLowerCase() : "";
11265
11515
  var GCP_SECURITY_COMBOS = [
11266
11516
  {
11267
- // Abierta a internet, sin una conexión en 7 días y facturando: se paga por una puerta que nadie usa.
11517
+ // Abierta a internet, sin uso en 7 días y facturando: se paga por una puerta que nadie usa.
11268
11518
  id: "COMBO_GCP_PUBLIC_SQL_IDLE",
11269
11519
  provider: "gcp",
11270
11520
  severity: "high",
@@ -11334,6 +11584,11 @@ var GCP_COMPUTE_CHECKS = {
11334
11584
  var GCP_SQL_CHECKS = {
11335
11585
  instances: "gcp:sql:instances"
11336
11586
  };
11587
+ var GCP_STORAGE_CHECKS = {
11588
+ buckets: "gcp:storage:buckets",
11589
+ // storage.buckets.getIamPolicy no está en roles/viewer ni en roles/storage.viewer: lo trae roles/iam.securityReviewer.
11590
+ iamPolicy: "gcp:storage:buckets.getIamPolicy"
11591
+ };
11337
11592
  var GCP_FUNCTIONS_CHECKS = {
11338
11593
  list: "gcp:functions:list"
11339
11594
  };
@@ -11408,6 +11663,28 @@ function evaluate7(observation) {
11408
11663
  evidence: { authorizedNetworks: nets }
11409
11664
  };
11410
11665
  }
11666
+ function gcsPublicoEvaluate(observation) {
11667
+ if (observation.attributes.publicAccessPrevention === "enforced") return { status: "ok" };
11668
+ const bindings = observation.attributes.publicBindings;
11669
+ if (!Array.isArray(bindings)) {
11670
+ return { status: "unknown", check: GCP_STORAGE_CHECKS.iamPolicy, attribute: "publicBindings" };
11671
+ }
11672
+ if (bindings.length === 0) return { status: "ok" };
11673
+ return {
11674
+ status: "finding",
11675
+ evidence: { publicBindings: bindings, publicAccessPrevention: observation.attributes.publicAccessPrevention ?? null }
11676
+ };
11677
+ }
11678
+ var gcsPublico = {
11679
+ id: "SEC_GCP_EXPOSURE_GCS_PUBLICO",
11680
+ scope: "resource",
11681
+ category: "security_exposure",
11682
+ severity: "critical",
11683
+ provider: "gcp",
11684
+ kinds: ["gcs_bucket"],
11685
+ requires: [GCP_STORAGE_CHECKS.buckets, GCP_STORAGE_CHECKS.iamPolicy],
11686
+ evaluate: gcsPublicoEvaluate
11687
+ };
11411
11688
  var sqlPublico2 = {
11412
11689
  id: "SEC_GCP_EXPOSURE_SQL_PUBLICO",
11413
11690
  scope: "resource",
@@ -11824,6 +12101,7 @@ var GCP_SECURITY_RULES = [
11824
12101
  firewallAdminAbierto,
11825
12102
  politicaAdminAbierta,
11826
12103
  sqlPublico2,
12104
+ gcsPublico,
11827
12105
  discoSinCmek,
11828
12106
  sqlSinCmek,
11829
12107
  secretoEnFunction,
@@ -11867,7 +12145,8 @@ var UNCOVERED_TYPES = /* @__PURE__ */ new Set([
11867
12145
  "google_compute_region_network_firewall_policy_rule",
11868
12146
  "google_logging_project_sink",
11869
12147
  "google_project_iam_audit_config",
11870
- // Sin regla viva todavía: un bucket de GCS público no se juzga ni antes ni después de desplegar.
12148
+ // SEC_GCP_EXPOSURE_GCS_PUBLICO juzga el bucket desplegado; el plan reparte su política en tres tipos que aún no se cruzan.
12149
+ "google_storage_bucket",
11871
12150
  "google_storage_bucket_iam_member",
11872
12151
  "google_storage_bucket_iam_binding",
11873
12152
  "google_storage_bucket_iam_policy"
@@ -11944,14 +12223,14 @@ function evaluatePlan(plan) {
11944
12223
  }))].sort();
11945
12224
  const accountRules = ALL_RULES.filter((rule) => rule.scope === "account" && providers.includes(rule.provider)).map((rule) => rule.id);
11946
12225
  const uncoveredTypes = [...new Set(index.resources.map((resource) => resource.type).filter((type) => UNCOVERED_TYPES.has(type)))].sort();
11947
- const bySeverity = (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.address.localeCompare(b.address);
12226
+ const bySeverity2 = (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.address.localeCompare(b.address);
11948
12227
  return {
11949
12228
  report: {
11950
12229
  providers,
11951
12230
  resourcesEvaluated: new Set(observations.map((observation) => observation.address)).size,
11952
- findings: planFindings.sort(bySeverity),
11953
- combos: combos.sort(bySeverity),
11954
- notEvaluable: notEvaluable.sort(bySeverity),
12231
+ findings: planFindings.sort(bySeverity2),
12232
+ combos: combos.sort(bySeverity2),
12233
+ notEvaluable: notEvaluable.sort(bySeverity2),
11955
12234
  accountRules,
11956
12235
  uncoveredTypes,
11957
12236
  passed
@@ -11967,7 +12246,7 @@ function blockingCount(report, threshold) {
11967
12246
 
11968
12247
  // src/security/render.ts
11969
12248
  import { createHash as createHash2 } from "node:crypto";
11970
- var SEVERITIES2 = ["critical", "high", "medium", "low"];
12249
+ var SEVERITIES3 = ["critical", "high", "medium", "low"];
11971
12250
  var BADGE = { critical: "\u{1F534} critical", high: "\u{1F7E0} high", medium: "\u{1F7E1} medium", low: "\u26AA low" };
11972
12251
  var GAP_TEXT = {
11973
12252
  known_after_apply: "value only known after apply",
@@ -11999,7 +12278,7 @@ function evidenceSummary(finding) {
11999
12278
  return parts.join("; ");
12000
12279
  }
12001
12280
  function counts(report) {
12002
- return SEVERITIES2.map((severity) => `${report.findings.filter((f) => f.severity === severity).length} ${severity}`).join(", ");
12281
+ return SEVERITIES3.map((severity) => `${report.findings.filter((f) => f.severity === severity).length} ${severity}`).join(", ");
12003
12282
  }
12004
12283
  function displayAddress(address) {
12005
12284
  return address.replace(/\["((?:[^"\\]|\\.)*)"\]/g, (_, key) => `[#${createHash2("sha256").update(key).digest("hex").slice(0, 8)}]`);
@@ -12018,18 +12297,18 @@ function groupedGaps(items) {
12018
12297
  function renderSecurityMarkdown(report) {
12019
12298
  const drift = report.drift?.items ?? [];
12020
12299
  if (report.resourcesEvaluated === 0 && report.uncoveredTypes.length === 0 && drift.length === 0) return null;
12021
- const lines = ["### \u{1F512} Security", ""];
12300
+ const lines2 = ["### \u{1F512} Security", ""];
12022
12301
  if (report.resourcesEvaluated === 0) {
12023
- lines.push("_No resource in this plan is covered by the security rules yet._");
12302
+ lines2.push("_No resource in this plan is covered by the security rules yet._");
12024
12303
  } else if (report.findings.length === 0) {
12025
- lines.push(
12304
+ lines2.push(
12026
12305
  `**No findings** on the ${report.resourcesEvaluated} resources checked` + (report.notEvaluable.length > 0 ? `, but ${groupedGaps(report.notEvaluable).length} checks could not be decided before apply (below).` : ".")
12027
12306
  );
12028
12307
  } else {
12029
- lines.push(`**Findings: ${counts(report)}** on ${report.resourcesEvaluated} resources checked.`);
12308
+ lines2.push(`**Findings: ${counts(report)}** on ${report.resourcesEvaluated} resources checked.`);
12030
12309
  }
12031
12310
  if (report.findings.length > 0) {
12032
- lines.push("", "| Severity | Rule | Resource | Change | Detail |", "|---|---|---|---|---|");
12311
+ lines2.push("", "| Severity | Rule | Resource | Change | Detail |", "|---|---|---|---|---|");
12033
12312
  const rows = /* @__PURE__ */ new Map();
12034
12313
  for (const finding of report.findings) {
12035
12314
  const key = `${finding.ruleId}#${finding.address}`;
@@ -12039,35 +12318,35 @@ function renderSecurityMarkdown(report) {
12039
12318
  }
12040
12319
  for (const { finding, count } of rows.values()) {
12041
12320
  const detail = [evidenceSummary(finding), count > 1 ? `\xD7${count}` : ""].filter(Boolean).join(" ");
12042
- lines.push(
12321
+ lines2.push(
12043
12322
  `| ${BADGE[finding.severity]} | ${cell(ruleTitle(finding.ruleId))} | ${code(finding.address)} | ${ACTION[finding.action] ?? finding.action} | ${cell(detail) || "\u2014"} |`
12044
12323
  );
12045
12324
  }
12046
12325
  }
12047
12326
  if (report.combos.length > 0) {
12048
- lines.push("", "**Toxic combinations** \u2014 findings that are worse together, fix these first:", "");
12049
- lines.push("| Severity | Combination | Resource |", "|---|---|---|");
12327
+ lines2.push("", "**Toxic combinations** \u2014 findings that are worse together, fix these first:", "");
12328
+ lines2.push("| Severity | Combination | Resource |", "|---|---|---|");
12050
12329
  for (const combo of report.combos) {
12051
- lines.push(`| ${BADGE[combo.severity]} | ${cell(comboTitle(combo.comboId))} | ${code(combo.address)} |`);
12330
+ lines2.push(`| ${BADGE[combo.severity]} | ${cell(comboTitle(combo.comboId))} | ${code(combo.address)} |`);
12052
12331
  }
12053
12332
  }
12054
12333
  if (drift.length > 0) {
12055
12334
  const when = report.drift?.scanAt ? ` of ${report.drift.scanAt.slice(0, 10)}` : "";
12056
- lines.push("", `**Last scan vs. this plan** (AAA Sentinel scan${when}):`, "");
12057
- lines.push("| Severity | Rule | Resource | What it means |", "|---|---|---|---|");
12335
+ lines2.push("", `**Last scan vs. this plan** (AAA Sentinel scan${when}):`, "");
12336
+ lines2.push("| Severity | Rule | Resource | What it means |", "|---|---|---|---|");
12058
12337
  for (const item of drift) {
12059
- lines.push(`| ${BADGE[item.severity]} | ${cell(ruleTitle(item.ruleId))} | ${code(item.address)} | ${DRIFT_TEXT[item.status]} |`);
12338
+ lines2.push(`| ${BADGE[item.severity]} | ${cell(ruleTitle(item.ruleId))} | ${code(item.address)} | ${DRIFT_TEXT[item.status]} |`);
12060
12339
  }
12061
12340
  }
12062
12341
  if (report.drift?.truncated) {
12063
- lines.push("", "_The live scan returned only its 500 most severe findings: drift on lower ones may be missing._");
12342
+ lines2.push("", "_The live scan returned only its 500 most severe findings: drift on lower ones may be missing._");
12064
12343
  }
12065
12344
  const gaps = groupedGaps(report.notEvaluable);
12066
12345
  if (gaps.length > 0) {
12067
- lines.push("", `<details><summary>Not evaluable before apply (${gaps.length})</summary>`, "");
12068
- lines.push("| Rule | Resource | Why |", "|---|---|---|");
12069
- for (const gap of gaps) lines.push(`| ${cell(ruleTitle(gap.ruleId))} | ${code(gap.address)} | ${GAP_TEXT[gap.reason]} |`);
12070
- lines.push("", "</details>");
12346
+ lines2.push("", `<details><summary>Not evaluable before apply (${gaps.length})</summary>`, "");
12347
+ lines2.push("| Rule | Resource | Why |", "|---|---|---|");
12348
+ for (const gap of gaps) lines2.push(`| ${cell(ruleTitle(gap.ruleId))} | ${code(gap.address)} | ${GAP_TEXT[gap.reason]} |`);
12349
+ lines2.push("", "</details>");
12071
12350
  }
12072
12351
  const notes = [];
12073
12352
  if (report.accountRules.length > 0) {
@@ -12077,31 +12356,31 @@ function renderSecurityMarkdown(report) {
12077
12356
  notes.push(`Present in this plan but not checked before deploy: ${report.uncoveredTypes.map(code).join(", ")}.`);
12078
12357
  }
12079
12358
  notes.push("Evaluated on your machine with the same rules as the AAA Sentinel posture scan; the plan is not sent anywhere for this.");
12080
- lines.push("", ...notes.map((note) => `> ${note}`));
12081
- return lines.join("\n");
12359
+ lines2.push("", ...notes.map((note) => `> ${note}`));
12360
+ return lines2.join("\n");
12082
12361
  }
12083
12362
  function renderSecurityText(report) {
12084
- const lines = ["Security"];
12085
- if (report.resourcesEvaluated === 0) lines.push(" No resource in this plan is covered by the security rules yet.");
12086
- else lines.push(` ${report.resourcesEvaluated} resources checked: ${counts(report)}`);
12363
+ const lines2 = ["Security"];
12364
+ if (report.resourcesEvaluated === 0) lines2.push(" No resource in this plan is covered by the security rules yet.");
12365
+ else lines2.push(` ${report.resourcesEvaluated} resources checked: ${counts(report)}`);
12087
12366
  for (const finding of report.findings) {
12088
12367
  const detail = evidenceSummary(finding);
12089
- lines.push(` [${finding.severity.toUpperCase()}] ${ruleTitle(finding.ruleId)} - ${displayAddress(finding.address)} (${ACTION[finding.action] ?? finding.action})${detail ? ` - ${detail.replace(/`/g, "")}` : ""}`);
12368
+ lines2.push(` [${finding.severity.toUpperCase()}] ${ruleTitle(finding.ruleId)} - ${displayAddress(finding.address)} (${ACTION[finding.action] ?? finding.action})${detail ? ` - ${detail.replace(/`/g, "")}` : ""}`);
12090
12369
  }
12091
12370
  for (const combo of report.combos) {
12092
- lines.push(` [${combo.severity.toUpperCase()}] combination: ${comboTitle(combo.comboId)} - ${displayAddress(combo.address)}`);
12371
+ lines2.push(` [${combo.severity.toUpperCase()}] combination: ${comboTitle(combo.comboId)} - ${displayAddress(combo.address)}`);
12093
12372
  }
12094
12373
  for (const item of report.drift?.items ?? []) {
12095
- lines.push(` [LAST SCAN] ${ruleTitle(item.ruleId)} - ${displayAddress(item.address)}: ${DRIFT_TEXT[item.status]}`);
12374
+ lines2.push(` [LAST SCAN] ${ruleTitle(item.ruleId)} - ${displayAddress(item.address)}: ${DRIFT_TEXT[item.status]}`);
12096
12375
  }
12097
12376
  const gaps = groupedGaps(report.notEvaluable);
12098
12377
  if (gaps.length > 0) {
12099
- lines.push(` Not evaluable before apply (${gaps.length}):`);
12100
- for (const gap of gaps) lines.push(` - ${ruleTitle(gap.ruleId)} - ${displayAddress(gap.address)}: ${GAP_TEXT[gap.reason]}`);
12378
+ lines2.push(` Not evaluable before apply (${gaps.length}):`);
12379
+ for (const gap of gaps) lines2.push(` - ${ruleTitle(gap.ruleId)} - ${displayAddress(gap.address)}: ${GAP_TEXT[gap.reason]}`);
12101
12380
  }
12102
- if (report.accountRules.length > 0) lines.push(` ${report.accountRules.length} account-wide rules are only checked by the live scan.`);
12103
- if (report.uncoveredTypes.length > 0) lines.push(` Not checked before deploy: ${report.uncoveredTypes.join(", ")}`);
12104
- return lines.join("\n");
12381
+ if (report.accountRules.length > 0) lines2.push(` ${report.accountRules.length} account-wide rules are only checked by the live scan.`);
12382
+ if (report.uncoveredTypes.length > 0) lines2.push(` Not checked before deploy: ${report.uncoveredTypes.join(", ")}`);
12383
+ return lines2.join("\n");
12105
12384
  }
12106
12385
 
12107
12386
  // src/run.ts
@@ -12144,10 +12423,10 @@ function decodePlan(bytes) {
12144
12423
  var fmtUsd2 = (n) => `${n < 0 ? "-" : "+"}$${Math.abs(n).toFixed(2)}/mo`;
12145
12424
  function costText(estimate) {
12146
12425
  const { coverage } = estimate;
12147
- const total = estimate.totalMonthlyDeltaUsd === null ? "no total (coverage too low)" : `${fmtUsd2(estimate.totalMonthlyDeltaUsd)} (list price, USD)`;
12426
+ const total2 = estimate.totalMonthlyDeltaUsd === null ? "no total (coverage too low)" : `${fmtUsd2(estimate.totalMonthlyDeltaUsd)} (list price, USD)`;
12148
12427
  return [
12149
12428
  "Cost",
12150
- ` Estimated change: ${total}`,
12429
+ ` Estimated change: ${total2}`,
12151
12430
  ` Coverage: ${coverage.pricedResources} of ${coverage.pricedResources + coverage.unpricedResources} billable resources priced`
12152
12431
  ].join("\n");
12153
12432
  }
@@ -12245,8 +12524,15 @@ async function fix(argv) {
12245
12524
  if (outcome.stdout) console.log(outcome.stdout);
12246
12525
  process.exitCode = outcome.exitCode;
12247
12526
  }
12527
+ async function posture(argv) {
12528
+ const outcome = await runPostureCli(argv, { env: process.env });
12529
+ for (const warning of outcome.warnings) console.warn(`iac-cost: ${warning}`);
12530
+ if (outcome.stdout) console.log(outcome.stdout);
12531
+ process.exitCode = outcome.exitCode;
12532
+ }
12248
12533
  async function main() {
12249
12534
  if (process.argv[2] === "fix") return fix(process.argv.slice(3));
12535
+ if (process.argv[2] === "posture") return posture(process.argv.slice(3));
12250
12536
  const options = parseCliArgs(process.argv.slice(2), process.env);
12251
12537
  if ("error" in options) {
12252
12538
  console.error(`iac-cost: ${options.error}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aaa-sentinel/iac-cost",
3
- "version": "0.3.0",
4
- "description": "Estimate the monthly cost delta of a Terraform plan, check it against the AAA Sentinel security rules and open security fixes as pull requests from your CI",
3
+ "version": "0.4.0",
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",
7
7
  "bin": {