@aaa-sentinel/iac-cost 0.3.0 → 0.3.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 +7 -2
  2. package/dist/cli.js +96 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,6 +7,9 @@ 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
+ > **Changed in 0.3.1:** the pull requests of `fix` pass `terraform fmt -check`: an added attribute is
11
+ > aligned with its group (also around heredocs and after removing a block), as `terraform fmt` does.
12
+
10
13
  > **New in 0.3.0:** the `fix` subcommand. Nothing changes for the existing command: same output,
11
14
  > same exit codes.
12
15
 
@@ -456,8 +459,10 @@ the reason, and the app shows it that way:
456
459
  | `rule_not_automated` | No deterministic code fix for that rule |
457
460
 
458
461
  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;
462
+ the comment lines right above it, up to the first blank line). An added attribute is aligned like
463
+ `terraform fmt` does: if its group of consecutive one-line attributes was already aligned, the whole
464
+ group is realigned to the longest name, so the pull request passes `terraform fmt -check`; if the
465
+ group was not formatted, only the new line is written as `name = value`. The pull request says what changed and why;
461
466
  review its `terraform plan` before merging. The finding closes after `terraform apply` and the next scan.
462
467
 
463
468
  | Option | Meaning |
package/dist/cli.js CHANGED
@@ -7100,8 +7100,68 @@ function removeListItems(text, attribute, list, remove) {
7100
7100
  const body2 = keep.map((item) => `${itemIndent}${text.slice(item.start, item.end)},${eol}`).join("");
7101
7101
  return { start: attribute.valueStart, end: attribute.valueEnd, text: `[${eol}${body2}${closeIndent}]` };
7102
7102
  }
7103
+ function singleLine(text, item) {
7104
+ if (item.kind !== "attribute") return false;
7105
+ return !text.slice(item.start, item.valueEnd).includes("\n") || text.startsWith("<<", item.valueStart);
7106
+ }
7107
+ function adjacent(text, previous, next) {
7108
+ const gap = text.slice(previous.valueEnd, next.start);
7109
+ const eol = gap.indexOf("\n");
7110
+ if (eol === -1 || gap.indexOf("\n", eol + 1) !== -1) return false;
7111
+ const tail = gap.slice(0, eol).trim();
7112
+ const comment = tail === "" || tail.startsWith("#") || tail.startsWith("//") || tail.startsWith("/*") && tail.endsWith("*/");
7113
+ return comment && gap.slice(eol + 1).trim() === "";
7114
+ }
7115
+ function alignmentGroups(text, block) {
7116
+ const groups = [];
7117
+ let current = [];
7118
+ for (const item of blockBody(text, block)) {
7119
+ const last = current[current.length - 1];
7120
+ if (singleLine(text, item) && last && adjacent(text, last, item)) {
7121
+ current.push(item);
7122
+ continue;
7123
+ }
7124
+ if (current.length > 0) groups.push(current);
7125
+ current = singleLine(text, item) ? [item] : [];
7126
+ }
7127
+ if (current.length > 0) groups.push(current);
7128
+ return groups;
7129
+ }
7130
+ function equalsSpan(text, attribute) {
7131
+ return { start: attribute.start + attribute.name.length, end: attribute.valueStart };
7132
+ }
7133
+ function fmtAligned(text, group) {
7134
+ const width = Math.max(...group.map((attribute) => attribute.name.length));
7135
+ return group.every((attribute) => {
7136
+ const span = equalsSpan(text, attribute);
7137
+ return text.slice(span.start, span.end) === `${" ".repeat(width - attribute.name.length + 1)}= `;
7138
+ });
7139
+ }
7140
+ function alignAttributes(text, block, names, original) {
7141
+ const edits = [];
7142
+ const before = original ? alignmentGroups(original.text, original.block) : null;
7143
+ for (const group of alignmentGroups(text, block)) {
7144
+ const targets = group.filter((attribute) => names.includes(attribute.name));
7145
+ if (targets.length === 0) continue;
7146
+ let aligned;
7147
+ if (before && original) {
7148
+ const contributing = new Set(group.flatMap((attribute) => before.filter((old) => old.some((a) => a.name === attribute.name))));
7149
+ aligned = [...contributing].every((old) => fmtAligned(original.text, old));
7150
+ } else {
7151
+ const others = group.filter((attribute) => !names.includes(attribute.name));
7152
+ aligned = others.length === 0 || fmtAligned(text, others);
7153
+ }
7154
+ const width = Math.max(...group.map((attribute) => attribute.name.length));
7155
+ for (const attribute of aligned ? group : targets) {
7156
+ const wanted = `${" ".repeat((aligned ? width : attribute.name.length) - attribute.name.length + 1)}= `;
7157
+ const span = equalsSpan(text, attribute);
7158
+ if (text.slice(span.start, span.end) !== wanted) edits.push({ ...span, text: wanted });
7159
+ }
7160
+ }
7161
+ return edits;
7162
+ }
7103
7163
  function applyEdits(text, edits) {
7104
- const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
7164
+ 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
7165
  let out = text;
7106
7166
  let limit = Number.POSITIVE_INFINITY;
7107
7167
  for (const edit2 of sorted) {
@@ -8244,6 +8304,19 @@ var ruleTitle = (id) => RULE_TITLES[id] ?? id;
8244
8304
  var comboTitle = (id) => COMBO_TITLES[id] ?? id;
8245
8305
 
8246
8306
  // src/fix/catalog.ts
8307
+ function finalizePatch(patch) {
8308
+ const find = (source, target) => parseBody(source, 0, source.length).find(
8309
+ (item) => item.kind === "block" && item.type === "resource" && item.labels[0] === target.type && item.labels[1] === target.name
8310
+ );
8311
+ let text = applyEdits(patch.original, patch.edits);
8312
+ for (const target of patch.align) {
8313
+ const block = find(text, target);
8314
+ if (!block) continue;
8315
+ const before = find(patch.original, target);
8316
+ text = applyEdits(text, alignAttributes(text, block, target.attributes, before ? { text: patch.original, block: before } : void 0));
8317
+ }
8318
+ return text;
8319
+ }
8247
8320
  var OPEN_CIDRS = /* @__PURE__ */ new Set(["0.0.0.0/0", "::/0"]);
8248
8321
  var ADMIN_PORTS = [22, 3389];
8249
8322
  var Suggest = class extends Error {
@@ -8295,10 +8368,16 @@ var PatchBuilder = class {
8295
8368
  byFile = /* @__PURE__ */ new Map();
8296
8369
  changes = [];
8297
8370
  first = null;
8298
- add(where, edit2, attribute, change, lifecycleName = attribute) {
8371
+ add(where, edit2, attribute, change, lifecycleName = attribute, alignName = null) {
8299
8372
  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: [] };
8373
+ const patch = this.byFile.get(where.absolutePath) ?? { file: where.file, absolutePath: where.absolutePath, original: where.text, edits: [], align: [] };
8301
8374
  patch.edits.push(edit2);
8375
+ const [type, name] = where.block.labels;
8376
+ if (alignName !== null && type && name) {
8377
+ const target = patch.align.find((a) => a.type === type && a.name === name);
8378
+ if (target) target.attributes.push(alignName);
8379
+ else patch.align.push({ type, name, attributes: [alignName] });
8380
+ }
8302
8381
  this.byFile.set(where.absolutePath, patch);
8303
8382
  this.changes.push(change);
8304
8383
  this.first ??= { file: where.file, attribute };
@@ -8327,7 +8406,10 @@ function setLiteral(builder, where, items, name, wanted, ok, insertWhenAbsent =
8327
8406
  where,
8328
8407
  edit(() => setAttribute(where.text, where.block, name, wanted), name),
8329
8408
  name,
8330
- before === null ? `add \`${name} = ${wanted}\`` : `\`${name}\`: \`${before}\` \u2192 \`${wanted}\``
8409
+ // Solo se alinea lo añadido: cambiar un valor no mueve el `=` de nadie.
8410
+ before === null ? `add \`${name} = ${wanted}\`` : `\`${name}\`: \`${before}\` \u2192 \`${wanted}\``,
8411
+ name,
8412
+ before === null ? name : null
8331
8413
  );
8332
8414
  return "changed";
8333
8415
  }
@@ -8439,21 +8521,25 @@ function awsAdminOpen(ctx, builder) {
8439
8521
  if (blocksOf(items, "dynamic").some((block) => block.labels[0] === "ingress")) throw new Suggest("dynamic_block", "ingress");
8440
8522
  if (attributeOf(items, "ingress")) throw new Suggest("value_from_expression", "ingress");
8441
8523
  let judged = 0;
8442
- const apply = (target, block, blockItems, shape, label) => {
8524
+ const apply = (target, block, blockItems, shape, label, alignName = null) => {
8443
8525
  const verdict = judgeRule(target.text, block, blockItems, shape, label);
8444
8526
  if (!verdict.open) return false;
8445
8527
  judged += 1;
8446
8528
  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));
8529
+ verdict.edits.forEach(
8530
+ (fn, i) => builder.add(target, edit(fn, verdict.attribute), verdict.attribute, verdict.changes[i] ?? verdict.changes[0] ?? "", lifecycleName, verdict.removesBlock ? alignName : null)
8531
+ );
8448
8532
  return verdict.removesBlock;
8449
8533
  };
8450
8534
  const inline = blocksOf(items, "ingress");
8451
8535
  let inlineRemoved = 0;
8452
8536
  for (const ingress of inline) {
8453
- if (apply(where, ingress, body(where.text, ingress), INLINE_RULE, `the \`ingress\` block of \`${anchor2.address}\``)) inlineRemoved += 1;
8537
+ const before = items[items.indexOf(ingress) - 1];
8538
+ const alignName = before?.kind === "attribute" ? before.name : null;
8539
+ if (apply(where, ingress, body(where.text, ingress), INLINE_RULE, `the \`ingress\` block of \`${anchor2.address}\``, alignName)) inlineRemoved += 1;
8454
8540
  }
8455
8541
  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");
8542
+ 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
8543
  }
8458
8544
  const parents = ["aws_security_group", "aws_default_security_group"];
8459
8545
  const children = [
@@ -8743,7 +8829,7 @@ async function runFix(options, deps) {
8743
8829
  if (outcome.kind === "patch") {
8744
8830
  try {
8745
8831
  for (const patch2 of outcome.patches) {
8746
- const patched = applyEdits(patch2.original, patch2.edits);
8832
+ const patched = finalizePatch(patch2);
8747
8833
  parseBody(patched, 0, patched.length);
8748
8834
  }
8749
8835
  } catch (error2) {
@@ -8834,7 +8920,7 @@ async function runFix(options, deps) {
8834
8920
  bases.push(atBase);
8835
8921
  }
8836
8922
  const files = patch.patches.map((file, i) => {
8837
- const patched = applyEdits(file.original, file.edits);
8923
+ const patched = finalizePatch(file);
8838
8924
  const atBase = bases[i] ?? file.original;
8839
8925
  return { path: file.file, content: atBase === file.original ? patched : likeBase(patched, atBase) };
8840
8926
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaa-sentinel/iac-cost",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
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",
5
5
  "license": "MIT",
6
6
  "type": "module",