@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.
- package/README.md +66 -2
- package/dist/cli.js +372 -86
- 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)
|
|
460
|
-
|
|
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
|
|
1728
|
-
const minIndent = Math.min(...
|
|
1729
|
-
const dedented =
|
|
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
|
|
6413
|
-
const totalMonthlyDeltaUsd = sufficientForTotal ? round2(
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
8883
|
-
if (options.format === "json") return JSON.stringify({ formatVersion: "1", accountId, scanAt, dryRun: options.dryRun, fixes:
|
|
8884
|
-
if (
|
|
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
|
|
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
|
|
9087
|
-
|
|
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
|
-
|
|
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
|
|
9095
|
-
|
|
9096
|
-
display ? `**Estimated change: ${
|
|
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
|
-
|
|
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
|
-
|
|
9104
|
-
|
|
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
|
-
|
|
9358
|
+
lines2.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
|
|
9109
9359
|
for (const item of estimate.priced) {
|
|
9110
|
-
|
|
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
|
-
|
|
9117
|
-
|
|
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
|
-
|
|
9369
|
+
lines2.push(`| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${reasonText(item.reason)} |`);
|
|
9120
9370
|
}
|
|
9121
|
-
|
|
9371
|
+
lines2.push("", "</details>");
|
|
9122
9372
|
}
|
|
9123
9373
|
const truncated = estimate.catalogs.filter((c) => c.truncated);
|
|
9124
9374
|
if (truncated.length > 0) {
|
|
9125
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
11953
|
-
combos: combos.sort(
|
|
11954
|
-
notEvaluable: notEvaluable.sort(
|
|
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
|
|
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
|
|
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
|
|
12300
|
+
const lines2 = ["### \u{1F512} Security", ""];
|
|
12022
12301
|
if (report.resourcesEvaluated === 0) {
|
|
12023
|
-
|
|
12302
|
+
lines2.push("_No resource in this plan is covered by the security rules yet._");
|
|
12024
12303
|
} else if (report.findings.length === 0) {
|
|
12025
|
-
|
|
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
|
-
|
|
12308
|
+
lines2.push(`**Findings: ${counts(report)}** on ${report.resourcesEvaluated} resources checked.`);
|
|
12030
12309
|
}
|
|
12031
12310
|
if (report.findings.length > 0) {
|
|
12032
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12049
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12057
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12068
|
-
|
|
12069
|
-
for (const gap of gaps)
|
|
12070
|
-
|
|
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
|
-
|
|
12081
|
-
return
|
|
12359
|
+
lines2.push("", ...notes.map((note) => `> ${note}`));
|
|
12360
|
+
return lines2.join("\n");
|
|
12082
12361
|
}
|
|
12083
12362
|
function renderSecurityText(report) {
|
|
12084
|
-
const
|
|
12085
|
-
if (report.resourcesEvaluated === 0)
|
|
12086
|
-
else
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12100
|
-
for (const gap of gaps)
|
|
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)
|
|
12103
|
-
if (report.uncoveredTypes.length > 0)
|
|
12104
|
-
return
|
|
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
|
|
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: ${
|
|
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.
|
|
4
|
-
"description": "Estimate the monthly cost delta of a Terraform plan, check it against the AAA Sentinel security rules
|
|
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": {
|