@aaa-sentinel/iac-cost 0.3.1 → 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 +59 -0
- package/dist/cli.js +276 -76
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,6 +7,10 @@ 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
|
+
|
|
10
14
|
> **Changed in 0.3.1:** the pull requests of `fix` pass `terraform fmt -check`: an added attribute is
|
|
11
15
|
> aligned with its group (also around heredocs and after removing a block), as `terraform fmt` does.
|
|
12
16
|
|
|
@@ -543,6 +547,61 @@ pipelines:
|
|
|
543
547
|
|
|
544
548
|
The commit is created through the Bitbucket API (`POST /src`), so the clone needs no push access.
|
|
545
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
|
+
|
|
546
605
|
## Why there is no comment
|
|
547
606
|
|
|
548
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()) {
|
|
@@ -8285,6 +8286,7 @@ var RULE_TITLES = {
|
|
|
8285
8286
|
SEC_AZURE_LOGGING_SIN_DIAGNOSTICO: "Subscription does not export Activity Log",
|
|
8286
8287
|
SEC_GCP_NETWORK_ADMIN_ABIERTO: "Firewall with admin ports open to the internet",
|
|
8287
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)",
|
|
8288
8290
|
SEC_GCP_ENCRYPTION_SQL_SIN_CMEK: "Cloud SQL without CMEK",
|
|
8289
8291
|
SEC_GCP_SECRETS_FUNCTION_ENV: "Secret in a Cloud Function environment variable",
|
|
8290
8292
|
SEC_GCP_IAM_ROL_PRIMITIVO: "Primitive Owner or Editor role on the project",
|
|
@@ -8779,7 +8781,7 @@ function carry(findingId, fix2, state) {
|
|
|
8779
8781
|
}
|
|
8780
8782
|
async function runFix(options, deps) {
|
|
8781
8783
|
const warnings = [];
|
|
8782
|
-
const
|
|
8784
|
+
const lines2 = [];
|
|
8783
8785
|
const reports = [];
|
|
8784
8786
|
const { host } = deps;
|
|
8785
8787
|
let input;
|
|
@@ -8787,7 +8789,7 @@ async function runFix(options, deps) {
|
|
|
8787
8789
|
input = await deps.api.load();
|
|
8788
8790
|
} catch (error2) {
|
|
8789
8791
|
warnings.push(`could not read the open findings, nothing to fix: ${error2.message}`);
|
|
8790
|
-
return { stdout: render(options,
|
|
8792
|
+
return { stdout: render(options, lines2, deps.accountId, null), warnings, lines: lines2, reported: 0 };
|
|
8791
8793
|
}
|
|
8792
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");
|
|
8793
8795
|
let publicRepo = false;
|
|
@@ -8818,7 +8820,7 @@ async function runFix(options, deps) {
|
|
|
8818
8820
|
}
|
|
8819
8821
|
if (state !== "merged") {
|
|
8820
8822
|
reports.push(carry(finding.findingId, existing, state));
|
|
8821
|
-
|
|
8823
|
+
lines2.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: existing.address, outcome: state, pullRequest: existing.pullRequest.url });
|
|
8822
8824
|
continue;
|
|
8823
8825
|
}
|
|
8824
8826
|
existing.state = "merged";
|
|
@@ -8840,7 +8842,7 @@ async function runFix(options, deps) {
|
|
|
8840
8842
|
const merged = existing && sameRepo && existing.state === "merged" && existing.pullRequest ? existing : null;
|
|
8841
8843
|
if (merged && outcome.kind !== "patch") {
|
|
8842
8844
|
reports.push(carry(finding.findingId, merged, "merged"));
|
|
8843
|
-
|
|
8845
|
+
lines2.push({ findingId: finding.findingId, ruleId: finding.ruleId, address: merged.address, outcome: "merged", ...merged.pullRequest ? { pullRequest: merged.pullRequest.url } : {} });
|
|
8844
8846
|
continue;
|
|
8845
8847
|
}
|
|
8846
8848
|
const base = { findingId: finding.findingId, ruleId: finding.ruleId, address: outcome.address };
|
|
@@ -8853,18 +8855,18 @@ async function runFix(options, deps) {
|
|
|
8853
8855
|
...outcome.file !== void 0 ? { file: outcome.file } : {},
|
|
8854
8856
|
...outcome.attribute !== void 0 ? { attribute: outcome.attribute } : {}
|
|
8855
8857
|
});
|
|
8856
|
-
|
|
8858
|
+
lines2.push({ ...base, outcome: "suggested", reason: outcome.reason, ...outcome.file !== void 0 ? { file: outcome.file } : {} });
|
|
8857
8859
|
continue;
|
|
8858
8860
|
}
|
|
8859
8861
|
if (outcome.kind === "fixed_in_code") {
|
|
8860
8862
|
reports.push({ findingId: finding.findingId, state: "pending", reason: "fixed_in_code", address: outcome.address, file: outcome.file, attribute: outcome.attribute });
|
|
8861
|
-
|
|
8863
|
+
lines2.push({ ...base, outcome: "pending", reason: "fixed_in_code", file: outcome.file });
|
|
8862
8864
|
continue;
|
|
8863
8865
|
}
|
|
8864
8866
|
const patch = outcome;
|
|
8865
8867
|
const pending = (reason) => {
|
|
8866
8868
|
reports.push({ findingId: finding.findingId, state: "pending", reason, address: patch.address, file: patch.file, attribute: patch.attribute });
|
|
8867
|
-
|
|
8869
|
+
lines2.push({ ...base, outcome: "pending", reason, file: patch.file, changes: patch.changes });
|
|
8868
8870
|
};
|
|
8869
8871
|
if (options.dryRun || !host) {
|
|
8870
8872
|
pending("dry_run");
|
|
@@ -8872,7 +8874,7 @@ async function runFix(options, deps) {
|
|
|
8872
8874
|
}
|
|
8873
8875
|
if (host.unsupported) {
|
|
8874
8876
|
reports.push({ findingId: finding.findingId, state: "suggested", reason: "unsupported_host", address: patch.address, file: patch.file, attribute: patch.attribute });
|
|
8875
|
-
|
|
8877
|
+
lines2.push({ ...base, outcome: "suggested", reason: "unsupported_host", file: patch.file, changes: patch.changes });
|
|
8876
8878
|
continue;
|
|
8877
8879
|
}
|
|
8878
8880
|
if (publicRepo) {
|
|
@@ -8896,7 +8898,7 @@ async function runFix(options, deps) {
|
|
|
8896
8898
|
repository: host.repository,
|
|
8897
8899
|
pullRequest: { url: pr.url, number: pr.number }
|
|
8898
8900
|
});
|
|
8899
|
-
|
|
8901
|
+
lines2.push({ ...base, outcome: state, file: patch.file, pullRequest: pr.url, changes: patch.changes });
|
|
8900
8902
|
};
|
|
8901
8903
|
try {
|
|
8902
8904
|
if (await host.branchExists(branch)) {
|
|
@@ -8946,7 +8948,7 @@ async function runFix(options, deps) {
|
|
|
8946
8948
|
warnings.push(`could not record the fixes in AAA Sentinel: ${error2.message}`);
|
|
8947
8949
|
}
|
|
8948
8950
|
}
|
|
8949
|
-
return { stdout: render(options,
|
|
8951
|
+
return { stdout: render(options, lines2, deps.accountId, input.scanAt), warnings, lines: lines2, reported };
|
|
8950
8952
|
}
|
|
8951
8953
|
var SkipFinding = class extends Error {
|
|
8952
8954
|
};
|
|
@@ -8965,11 +8967,11 @@ var OUTCOME_TEXT = {
|
|
|
8965
8967
|
pending: "pending",
|
|
8966
8968
|
suggested: "change suggested, no pull request"
|
|
8967
8969
|
};
|
|
8968
|
-
function render(options,
|
|
8969
|
-
if (options.format === "json") return JSON.stringify({ formatVersion: "1", accountId, scanAt, dryRun: options.dryRun, fixes:
|
|
8970
|
-
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.`;
|
|
8971
8973
|
const out = [`Security fixes for account ${accountId}${options.dryRun ? " (dry run: nothing opened or recorded)" : ""}`];
|
|
8972
|
-
for (const line of
|
|
8974
|
+
for (const line of lines2) {
|
|
8973
8975
|
const detail = [line.reason, line.file, line.pullRequest].filter(Boolean).join(", ");
|
|
8974
8976
|
out.push(` ${line.address} \u2014 ${title(line.ruleId)}: ${OUTCOME_TEXT[line.outcome]}${detail ? ` (${detail})` : ""}`);
|
|
8975
8977
|
for (const change of line.changes ?? []) out.push(` ${change}`);
|
|
@@ -9078,6 +9080,168 @@ ${FIX_USAGE}`);
|
|
|
9078
9080
|
return { stdout: result.stdout, warnings: [...warnings, ...result.warnings], exitCode: 0 };
|
|
9079
9081
|
}
|
|
9080
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
|
+
|
|
9081
9245
|
// src/terraform-plan.ts
|
|
9082
9246
|
function actionOf2(actions) {
|
|
9083
9247
|
if (actions.includes("create") && actions.includes("delete")) return "replace";
|
|
@@ -9169,51 +9333,51 @@ function actionVerb(action) {
|
|
|
9169
9333
|
}
|
|
9170
9334
|
function renderComment(estimate) {
|
|
9171
9335
|
const { coverage } = estimate;
|
|
9172
|
-
const
|
|
9173
|
-
|
|
9336
|
+
const lines2 = [COMMENT_MARKER, "### \u{1F4B2} Infrastructure cost estimate", ""];
|
|
9337
|
+
lines2.push(
|
|
9174
9338
|
`**Coverage: ${coverage.pricedResources} of ${coverage.pricedResources + coverage.unpricedResources} billable resources priced** (${coverage.freeResources} more have no cost by design).`
|
|
9175
9339
|
);
|
|
9176
|
-
|
|
9340
|
+
lines2.push("");
|
|
9177
9341
|
const display = estimate.displayCurrency?.approximate ? estimate.displayCurrency : null;
|
|
9178
9342
|
const delta = (usd, converted) => display && typeof converted === "number" ? fmtConverted(converted, display.code) : fmtUsd(usd);
|
|
9179
9343
|
if (estimate.totalMonthlyDeltaUsd !== null) {
|
|
9180
|
-
const
|
|
9181
|
-
|
|
9182
|
-
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)`
|
|
9183
9347
|
);
|
|
9184
9348
|
} else {
|
|
9185
|
-
|
|
9349
|
+
lines2.push(
|
|
9186
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._`
|
|
9187
9351
|
);
|
|
9188
9352
|
}
|
|
9189
|
-
|
|
9190
|
-
|
|
9353
|
+
lines2.push("");
|
|
9354
|
+
lines2.push(
|
|
9191
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}`
|
|
9192
9356
|
);
|
|
9193
9357
|
if (estimate.priced.length > 0) {
|
|
9194
|
-
|
|
9358
|
+
lines2.push("", "| Resource | Change | Qty | Delta |", "|---|---|---|---|");
|
|
9195
9359
|
for (const item of estimate.priced) {
|
|
9196
|
-
|
|
9360
|
+
lines2.push(
|
|
9197
9361
|
`| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${delta(item.monthlyDeltaUsd, item.monthlyDelta)} |`
|
|
9198
9362
|
);
|
|
9199
9363
|
}
|
|
9200
9364
|
}
|
|
9201
9365
|
if (estimate.unpriced.length > 0) {
|
|
9202
|
-
|
|
9203
|
-
|
|
9366
|
+
lines2.push("", "<details><summary>Not priced (" + coverage.unpricedResources + " resources)</summary>", "");
|
|
9367
|
+
lines2.push("| Resource | Change | Qty | Why |", "|---|---|---|---|");
|
|
9204
9368
|
for (const item of estimate.unpriced) {
|
|
9205
|
-
|
|
9369
|
+
lines2.push(`| \`${item.type}\` | ${actionVerb(item.action)} | ${item.count} | ${reasonText(item.reason)} |`);
|
|
9206
9370
|
}
|
|
9207
|
-
|
|
9371
|
+
lines2.push("", "</details>");
|
|
9208
9372
|
}
|
|
9209
9373
|
const truncated = estimate.catalogs.filter((c) => c.truncated);
|
|
9210
9374
|
if (truncated.length > 0) {
|
|
9211
|
-
|
|
9375
|
+
lines2.push(
|
|
9212
9376
|
"",
|
|
9213
9377
|
`\u26A0\uFE0F The price catalog for ${truncated.map((c) => c.region).join(", ")} was truncated by pagination: some prices may be missing.`
|
|
9214
9378
|
);
|
|
9215
9379
|
}
|
|
9216
|
-
return
|
|
9380
|
+
return lines2.join("\n");
|
|
9217
9381
|
}
|
|
9218
9382
|
|
|
9219
9383
|
// src/security/drift.ts
|
|
@@ -11350,7 +11514,7 @@ var AZURE_SECURITY_RULES = [
|
|
|
11350
11514
|
var minus = (s) => typeof s === "string" ? s.toLowerCase() : "";
|
|
11351
11515
|
var GCP_SECURITY_COMBOS = [
|
|
11352
11516
|
{
|
|
11353
|
-
// Abierta a internet, sin
|
|
11517
|
+
// Abierta a internet, sin uso en 7 días y facturando: se paga por una puerta que nadie usa.
|
|
11354
11518
|
id: "COMBO_GCP_PUBLIC_SQL_IDLE",
|
|
11355
11519
|
provider: "gcp",
|
|
11356
11520
|
severity: "high",
|
|
@@ -11420,6 +11584,11 @@ var GCP_COMPUTE_CHECKS = {
|
|
|
11420
11584
|
var GCP_SQL_CHECKS = {
|
|
11421
11585
|
instances: "gcp:sql:instances"
|
|
11422
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
|
+
};
|
|
11423
11592
|
var GCP_FUNCTIONS_CHECKS = {
|
|
11424
11593
|
list: "gcp:functions:list"
|
|
11425
11594
|
};
|
|
@@ -11494,6 +11663,28 @@ function evaluate7(observation) {
|
|
|
11494
11663
|
evidence: { authorizedNetworks: nets }
|
|
11495
11664
|
};
|
|
11496
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
|
+
};
|
|
11497
11688
|
var sqlPublico2 = {
|
|
11498
11689
|
id: "SEC_GCP_EXPOSURE_SQL_PUBLICO",
|
|
11499
11690
|
scope: "resource",
|
|
@@ -11910,6 +12101,7 @@ var GCP_SECURITY_RULES = [
|
|
|
11910
12101
|
firewallAdminAbierto,
|
|
11911
12102
|
politicaAdminAbierta,
|
|
11912
12103
|
sqlPublico2,
|
|
12104
|
+
gcsPublico,
|
|
11913
12105
|
discoSinCmek,
|
|
11914
12106
|
sqlSinCmek,
|
|
11915
12107
|
secretoEnFunction,
|
|
@@ -11953,7 +12145,8 @@ var UNCOVERED_TYPES = /* @__PURE__ */ new Set([
|
|
|
11953
12145
|
"google_compute_region_network_firewall_policy_rule",
|
|
11954
12146
|
"google_logging_project_sink",
|
|
11955
12147
|
"google_project_iam_audit_config",
|
|
11956
|
-
//
|
|
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",
|
|
11957
12150
|
"google_storage_bucket_iam_member",
|
|
11958
12151
|
"google_storage_bucket_iam_binding",
|
|
11959
12152
|
"google_storage_bucket_iam_policy"
|
|
@@ -12030,14 +12223,14 @@ function evaluatePlan(plan) {
|
|
|
12030
12223
|
}))].sort();
|
|
12031
12224
|
const accountRules = ALL_RULES.filter((rule) => rule.scope === "account" && providers.includes(rule.provider)).map((rule) => rule.id);
|
|
12032
12225
|
const uncoveredTypes = [...new Set(index.resources.map((resource) => resource.type).filter((type) => UNCOVERED_TYPES.has(type)))].sort();
|
|
12033
|
-
const
|
|
12226
|
+
const bySeverity2 = (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.address.localeCompare(b.address);
|
|
12034
12227
|
return {
|
|
12035
12228
|
report: {
|
|
12036
12229
|
providers,
|
|
12037
12230
|
resourcesEvaluated: new Set(observations.map((observation) => observation.address)).size,
|
|
12038
|
-
findings: planFindings.sort(
|
|
12039
|
-
combos: combos.sort(
|
|
12040
|
-
notEvaluable: notEvaluable.sort(
|
|
12231
|
+
findings: planFindings.sort(bySeverity2),
|
|
12232
|
+
combos: combos.sort(bySeverity2),
|
|
12233
|
+
notEvaluable: notEvaluable.sort(bySeverity2),
|
|
12041
12234
|
accountRules,
|
|
12042
12235
|
uncoveredTypes,
|
|
12043
12236
|
passed
|
|
@@ -12053,7 +12246,7 @@ function blockingCount(report, threshold) {
|
|
|
12053
12246
|
|
|
12054
12247
|
// src/security/render.ts
|
|
12055
12248
|
import { createHash as createHash2 } from "node:crypto";
|
|
12056
|
-
var
|
|
12249
|
+
var SEVERITIES3 = ["critical", "high", "medium", "low"];
|
|
12057
12250
|
var BADGE = { critical: "\u{1F534} critical", high: "\u{1F7E0} high", medium: "\u{1F7E1} medium", low: "\u26AA low" };
|
|
12058
12251
|
var GAP_TEXT = {
|
|
12059
12252
|
known_after_apply: "value only known after apply",
|
|
@@ -12085,7 +12278,7 @@ function evidenceSummary(finding) {
|
|
|
12085
12278
|
return parts.join("; ");
|
|
12086
12279
|
}
|
|
12087
12280
|
function counts(report) {
|
|
12088
|
-
return
|
|
12281
|
+
return SEVERITIES3.map((severity) => `${report.findings.filter((f) => f.severity === severity).length} ${severity}`).join(", ");
|
|
12089
12282
|
}
|
|
12090
12283
|
function displayAddress(address) {
|
|
12091
12284
|
return address.replace(/\["((?:[^"\\]|\\.)*)"\]/g, (_, key) => `[#${createHash2("sha256").update(key).digest("hex").slice(0, 8)}]`);
|
|
@@ -12104,18 +12297,18 @@ function groupedGaps(items) {
|
|
|
12104
12297
|
function renderSecurityMarkdown(report) {
|
|
12105
12298
|
const drift = report.drift?.items ?? [];
|
|
12106
12299
|
if (report.resourcesEvaluated === 0 && report.uncoveredTypes.length === 0 && drift.length === 0) return null;
|
|
12107
|
-
const
|
|
12300
|
+
const lines2 = ["### \u{1F512} Security", ""];
|
|
12108
12301
|
if (report.resourcesEvaluated === 0) {
|
|
12109
|
-
|
|
12302
|
+
lines2.push("_No resource in this plan is covered by the security rules yet._");
|
|
12110
12303
|
} else if (report.findings.length === 0) {
|
|
12111
|
-
|
|
12304
|
+
lines2.push(
|
|
12112
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).` : ".")
|
|
12113
12306
|
);
|
|
12114
12307
|
} else {
|
|
12115
|
-
|
|
12308
|
+
lines2.push(`**Findings: ${counts(report)}** on ${report.resourcesEvaluated} resources checked.`);
|
|
12116
12309
|
}
|
|
12117
12310
|
if (report.findings.length > 0) {
|
|
12118
|
-
|
|
12311
|
+
lines2.push("", "| Severity | Rule | Resource | Change | Detail |", "|---|---|---|---|---|");
|
|
12119
12312
|
const rows = /* @__PURE__ */ new Map();
|
|
12120
12313
|
for (const finding of report.findings) {
|
|
12121
12314
|
const key = `${finding.ruleId}#${finding.address}`;
|
|
@@ -12125,35 +12318,35 @@ function renderSecurityMarkdown(report) {
|
|
|
12125
12318
|
}
|
|
12126
12319
|
for (const { finding, count } of rows.values()) {
|
|
12127
12320
|
const detail = [evidenceSummary(finding), count > 1 ? `\xD7${count}` : ""].filter(Boolean).join(" ");
|
|
12128
|
-
|
|
12321
|
+
lines2.push(
|
|
12129
12322
|
`| ${BADGE[finding.severity]} | ${cell(ruleTitle(finding.ruleId))} | ${code(finding.address)} | ${ACTION[finding.action] ?? finding.action} | ${cell(detail) || "\u2014"} |`
|
|
12130
12323
|
);
|
|
12131
12324
|
}
|
|
12132
12325
|
}
|
|
12133
12326
|
if (report.combos.length > 0) {
|
|
12134
|
-
|
|
12135
|
-
|
|
12327
|
+
lines2.push("", "**Toxic combinations** \u2014 findings that are worse together, fix these first:", "");
|
|
12328
|
+
lines2.push("| Severity | Combination | Resource |", "|---|---|---|");
|
|
12136
12329
|
for (const combo of report.combos) {
|
|
12137
|
-
|
|
12330
|
+
lines2.push(`| ${BADGE[combo.severity]} | ${cell(comboTitle(combo.comboId))} | ${code(combo.address)} |`);
|
|
12138
12331
|
}
|
|
12139
12332
|
}
|
|
12140
12333
|
if (drift.length > 0) {
|
|
12141
12334
|
const when = report.drift?.scanAt ? ` of ${report.drift.scanAt.slice(0, 10)}` : "";
|
|
12142
|
-
|
|
12143
|
-
|
|
12335
|
+
lines2.push("", `**Last scan vs. this plan** (AAA Sentinel scan${when}):`, "");
|
|
12336
|
+
lines2.push("| Severity | Rule | Resource | What it means |", "|---|---|---|---|");
|
|
12144
12337
|
for (const item of drift) {
|
|
12145
|
-
|
|
12338
|
+
lines2.push(`| ${BADGE[item.severity]} | ${cell(ruleTitle(item.ruleId))} | ${code(item.address)} | ${DRIFT_TEXT[item.status]} |`);
|
|
12146
12339
|
}
|
|
12147
12340
|
}
|
|
12148
12341
|
if (report.drift?.truncated) {
|
|
12149
|
-
|
|
12342
|
+
lines2.push("", "_The live scan returned only its 500 most severe findings: drift on lower ones may be missing._");
|
|
12150
12343
|
}
|
|
12151
12344
|
const gaps = groupedGaps(report.notEvaluable);
|
|
12152
12345
|
if (gaps.length > 0) {
|
|
12153
|
-
|
|
12154
|
-
|
|
12155
|
-
for (const gap of gaps)
|
|
12156
|
-
|
|
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>");
|
|
12157
12350
|
}
|
|
12158
12351
|
const notes = [];
|
|
12159
12352
|
if (report.accountRules.length > 0) {
|
|
@@ -12163,31 +12356,31 @@ function renderSecurityMarkdown(report) {
|
|
|
12163
12356
|
notes.push(`Present in this plan but not checked before deploy: ${report.uncoveredTypes.map(code).join(", ")}.`);
|
|
12164
12357
|
}
|
|
12165
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.");
|
|
12166
|
-
|
|
12167
|
-
return
|
|
12359
|
+
lines2.push("", ...notes.map((note) => `> ${note}`));
|
|
12360
|
+
return lines2.join("\n");
|
|
12168
12361
|
}
|
|
12169
12362
|
function renderSecurityText(report) {
|
|
12170
|
-
const
|
|
12171
|
-
if (report.resourcesEvaluated === 0)
|
|
12172
|
-
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)}`);
|
|
12173
12366
|
for (const finding of report.findings) {
|
|
12174
12367
|
const detail = evidenceSummary(finding);
|
|
12175
|
-
|
|
12368
|
+
lines2.push(` [${finding.severity.toUpperCase()}] ${ruleTitle(finding.ruleId)} - ${displayAddress(finding.address)} (${ACTION[finding.action] ?? finding.action})${detail ? ` - ${detail.replace(/`/g, "")}` : ""}`);
|
|
12176
12369
|
}
|
|
12177
12370
|
for (const combo of report.combos) {
|
|
12178
|
-
|
|
12371
|
+
lines2.push(` [${combo.severity.toUpperCase()}] combination: ${comboTitle(combo.comboId)} - ${displayAddress(combo.address)}`);
|
|
12179
12372
|
}
|
|
12180
12373
|
for (const item of report.drift?.items ?? []) {
|
|
12181
|
-
|
|
12374
|
+
lines2.push(` [LAST SCAN] ${ruleTitle(item.ruleId)} - ${displayAddress(item.address)}: ${DRIFT_TEXT[item.status]}`);
|
|
12182
12375
|
}
|
|
12183
12376
|
const gaps = groupedGaps(report.notEvaluable);
|
|
12184
12377
|
if (gaps.length > 0) {
|
|
12185
|
-
|
|
12186
|
-
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]}`);
|
|
12187
12380
|
}
|
|
12188
|
-
if (report.accountRules.length > 0)
|
|
12189
|
-
if (report.uncoveredTypes.length > 0)
|
|
12190
|
-
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");
|
|
12191
12384
|
}
|
|
12192
12385
|
|
|
12193
12386
|
// src/run.ts
|
|
@@ -12230,10 +12423,10 @@ function decodePlan(bytes) {
|
|
|
12230
12423
|
var fmtUsd2 = (n) => `${n < 0 ? "-" : "+"}$${Math.abs(n).toFixed(2)}/mo`;
|
|
12231
12424
|
function costText(estimate) {
|
|
12232
12425
|
const { coverage } = estimate;
|
|
12233
|
-
const
|
|
12426
|
+
const total2 = estimate.totalMonthlyDeltaUsd === null ? "no total (coverage too low)" : `${fmtUsd2(estimate.totalMonthlyDeltaUsd)} (list price, USD)`;
|
|
12234
12427
|
return [
|
|
12235
12428
|
"Cost",
|
|
12236
|
-
` Estimated change: ${
|
|
12429
|
+
` Estimated change: ${total2}`,
|
|
12237
12430
|
` Coverage: ${coverage.pricedResources} of ${coverage.pricedResources + coverage.unpricedResources} billable resources priced`
|
|
12238
12431
|
].join("\n");
|
|
12239
12432
|
}
|
|
@@ -12331,8 +12524,15 @@ async function fix(argv) {
|
|
|
12331
12524
|
if (outcome.stdout) console.log(outcome.stdout);
|
|
12332
12525
|
process.exitCode = outcome.exitCode;
|
|
12333
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
|
+
}
|
|
12334
12533
|
async function main() {
|
|
12335
12534
|
if (process.argv[2] === "fix") return fix(process.argv.slice(3));
|
|
12535
|
+
if (process.argv[2] === "posture") return posture(process.argv.slice(3));
|
|
12336
12536
|
const options = parseCliArgs(process.argv.slice(2), process.env);
|
|
12337
12537
|
if ("error" in options) {
|
|
12338
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": {
|