@aaa-sentinel/iac-cost 0.1.1 → 0.3.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 +390 -29
- package/dist/cli.js +11843 -4089
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
# @aaa-sentinel/iac-cost
|
|
2
2
|
|
|
3
3
|
Comments the **monthly cost delta of a Terraform plan** on the pull request that introduces it,
|
|
4
|
-
so the price of a change is visible while it is still cheap to change
|
|
4
|
+
so the price of a change is visible while it is still cheap to change — and checks the same plan
|
|
5
|
+
against the **security rules of the AAA Sentinel posture scan**, so an SSH port open to the
|
|
6
|
+
internet is caught in review instead of after the deploy. With `iac-cost fix`, the findings of
|
|
7
|
+
your last scan on resources this Terraform manages come back to you as **pull requests**, not as
|
|
8
|
+
console changes (see [Fixes as pull requests](#fixes-as-pull-requests)).
|
|
9
|
+
|
|
10
|
+
> **New in 0.3.0:** the `fix` subcommand. Nothing changes for the existing command: same output,
|
|
11
|
+
> same exit codes.
|
|
12
|
+
|
|
13
|
+
> **Changed in 0.2.0:** stdout now carries a `### 🔒 Security` section after the cost estimate
|
|
14
|
+
> (or alone, under the same comment marker, when there is no cost estimate), so a plan with no
|
|
15
|
+
> billable change can still produce a comment. Pass `--no-security` to get exactly the 0.1 output.
|
|
16
|
+
> The exit code stays 0 unless you add `--fail-on`.
|
|
5
17
|
|
|
6
18
|
> ### 💲 Infrastructure cost estimate
|
|
7
19
|
>
|
|
@@ -18,12 +30,17 @@ so the price of a change is visible while it is still cheap to change.
|
|
|
18
30
|
|
|
19
31
|
## Requirements
|
|
20
32
|
|
|
21
|
-
- Node.js 20 or newer.
|
|
22
|
-
-
|
|
33
|
+
- Node.js 20 or newer. That is all the **security check** needs: it runs offline.
|
|
34
|
+
- For the **cost estimate**, an OAuth2 application with the `iac:estimate` scope, created in the
|
|
23
35
|
[developer portal](https://app.aaa-sentinel.com/developer/apps).
|
|
24
36
|
That scope grants **nothing else** — it cannot read your accounts, findings or reports —
|
|
25
|
-
which is what makes it safe to paste into a CI that third parties can trigger.
|
|
26
|
-
|
|
37
|
+
which is what makes it safe to paste into a CI that third parties can trigger. The cost
|
|
38
|
+
estimate needs a **Pro** or **Business** plan.
|
|
39
|
+
- For the optional **drift check**, a second application with `security:read` and the security
|
|
40
|
+
add-on on the account (see [Drift](#drift-plan-vs-cloud)).
|
|
41
|
+
- For **`iac-cost fix`**, an application with the `security:fix` scope, the security add-on on the
|
|
42
|
+
account, and a CI token that can push a branch and open a pull request (see
|
|
43
|
+
[Fixes as pull requests](#fixes-as-pull-requests)).
|
|
27
44
|
|
|
28
45
|
## Usage
|
|
29
46
|
|
|
@@ -39,15 +56,41 @@ npx @aaa-sentinel/iac-cost plan.json # or: … | npx @aaa-sentinel/iac-
|
|
|
39
56
|
| `IAC_COST_CLIENT_ID` | yes | Client ID of the OAuth2 application |
|
|
40
57
|
| `IAC_COST_CLIENT_SECRET` | yes | Client secret. Store it as an encrypted CI secret |
|
|
41
58
|
| `IAC_COST_BASE_URL` | no | Defaults to `https://api.aaa-sentinel.com`. Must be `https` — the secret is never sent over plain HTTP |
|
|
59
|
+
| `IAC_SECURITY_FAIL_ON` | no | Same as `--fail-on` |
|
|
60
|
+
| `IAC_SECURITY_ACCOUNT_ID` | no | Same as `--drift-account` |
|
|
61
|
+
| `IAC_SECURITY_CLIENT_ID` / `IAC_SECURITY_CLIENT_SECRET` | for drift | An OAuth2 application with the `security:read` scope |
|
|
42
62
|
|
|
43
63
|
The Markdown comment goes to **stdout**; warnings go to **stderr**. If there is nothing to
|
|
44
64
|
say, stdout is empty.
|
|
45
65
|
|
|
46
|
-
|
|
66
|
+
| Option | Meaning |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `--format markdown` | The pull request comment (default) |
|
|
69
|
+
| `--format text` | Plain lines for a terminal or a CI log |
|
|
70
|
+
| `--format json` | `{ formatVersion, cost, security, warnings }`, with the full evidence of each finding. It is evidence for you, not for a pull request: it keeps the Terraform addresses as written (for_each keys included) and the evidence as the rules see it — ARNs, subscription ids, emails or internal CIDRs of resources that already exist. Do not post it as a comment |
|
|
71
|
+
| `--fail-on <critical\|high\|medium\|low>` | Exit 1 when a security finding or toxic combination is at that severity or worse. Also `IAC_SECURITY_FAIL_ON` |
|
|
72
|
+
| `--no-security` | Cost only, exactly as in 0.1 |
|
|
73
|
+
| `--no-cost` | Security only and no credentials needed. Nothing leaves your machine unless you also pass `--drift-account` explicitly: with `--no-cost`, `IAC_SECURITY_ACCOUNT_ID` in the environment is ignored |
|
|
74
|
+
| `--drift-account <accountId>` | Compare with the last scan of that account (see [Drift](#drift-plan-vs-cloud)). Also `IAC_SECURITY_ACCOUNT_ID`, except with `--no-cost`. It puts what the scan found in your cloud into the PR comment: **never on a public repository** |
|
|
75
|
+
|
|
76
|
+
### It never fails your build — unless you ask for a gate
|
|
77
|
+
|
|
78
|
+
Without `--fail-on` the exit code is **always 0**. A missing credential, a network error, an
|
|
79
|
+
unreadable plan or an outage on our side prints a one-line warning and stops. A cost comment is
|
|
80
|
+
not a quality gate, and our availability must not become a dependency of your deployments.
|
|
47
81
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
82
|
+
With `--fail-on high` you are asking for a gate, and the exit code becomes:
|
|
83
|
+
|
|
84
|
+
| Code | When |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `0` | No finding or combination at `high` or worse |
|
|
87
|
+
| `1` | At least one — block the merge |
|
|
88
|
+
| `2` | The input is not a readable plan (unreadable JSON, the state instead of a plan, `errored: true`) or `--fail-on` has a value that is not a severity. A gate that cannot read its input does not open by itself |
|
|
89
|
+
|
|
90
|
+
Any other unknown or malformed argument only prints a warning, with or without a gate.
|
|
91
|
+
|
|
92
|
+
Only the plan decides the code. The cost estimate and the drift check go over the network, and
|
|
93
|
+
neither can change it. A check that **could not be decided** (see below) never blocks.
|
|
51
94
|
|
|
52
95
|
## What actually leaves your machine
|
|
53
96
|
|
|
@@ -57,6 +100,19 @@ whitelist: resource type, action (`create`/`update`/`delete`/`replace`), how man
|
|
|
57
100
|
block produces, and the handful of attributes that move the price (instance size, region, OS).
|
|
58
101
|
Everything else is dropped before serialising, and the projection is never written to the CI log.
|
|
59
102
|
|
|
103
|
+
The security check sends nothing: it runs entirely on your machine. The drift check sends only
|
|
104
|
+
the account id you give it, and receives the findings of your last scan. With `--no-cost`, the
|
|
105
|
+
only way to make a network call is to pass `--drift-account` on the command line.
|
|
106
|
+
|
|
107
|
+
`iac-cost fix` reads the open findings of the account (rule, cloud id, region) and sends back, per
|
|
108
|
+
finding it matched: the Terraform address, the `.tf` path relative to the repository, the attribute,
|
|
109
|
+
the repository name and the pull request link and number. Never the plan, the code or your git
|
|
110
|
+
token: the branch, the commit and the pull request are created by your CI against your git host.
|
|
111
|
+
|
|
112
|
+
The PR comment and the text output name each resource by its Terraform address, with the key of a
|
|
113
|
+
`for_each` replaced by a short hash (`aws_s3_bucket.b[#3fa2c19d]`), because keys are often emails
|
|
114
|
+
or account ids. The JSON output does not: it is the complete evidence (see `--format json`).
|
|
115
|
+
|
|
60
116
|
## Reading the number
|
|
61
117
|
|
|
62
118
|
- **List price.** Neither AWS, Azure nor Google returns what *you* pay. Enterprise agreements
|
|
@@ -79,6 +135,144 @@ Everything else is dropped before serialising, and the projection is never writt
|
|
|
79
135
|
Currently priced: **AWS** (EC2 on demand) and **Azure** (virtual machines). Anything else is
|
|
80
136
|
reported as not priced, never as free.
|
|
81
137
|
|
|
138
|
+
## Security before deploy
|
|
139
|
+
|
|
140
|
+
The plan is checked **on your machine** with the rules of the AAA Sentinel posture scan — the
|
|
141
|
+
same code, bundled into this package, not a copy. A rule that flags an open security group in
|
|
142
|
+
your cloud flags it in the plan that would create it, with the same severity. The security part
|
|
143
|
+
sends nothing anywhere and needs no credentials.
|
|
144
|
+
|
|
145
|
+
> ### 🔒 Security
|
|
146
|
+
>
|
|
147
|
+
> **Findings: 2 critical, 1 high, 0 medium, 0 low** on 6 resources checked.
|
|
148
|
+
>
|
|
149
|
+
> | Severity | Rule | Resource | Change | Detail |
|
|
150
|
+
> |---|---|---|---|---|
|
|
151
|
+
> | 🔴 critical | Admin ports open to the internet | `aws_security_group.bastion` | new | ports 22 |
|
|
152
|
+
> | 🔴 critical | Lambda invocable from the internet | `aws_lambda_function.api` | new | function URL without auth |
|
|
153
|
+
> | 🟠 high | Secret in a Lambda environment variable | `aws_lambda_function.api` | new | variables DB_PASSWORD |
|
|
154
|
+
>
|
|
155
|
+
> **Toxic combinations** — findings that are worse together, fix these first:
|
|
156
|
+
>
|
|
157
|
+
> | Severity | Combination | Resource |
|
|
158
|
+
> |---|---|---|
|
|
159
|
+
> | 🔴 critical | Public function with secrets in its configuration | `aws_lambda_function.api` |
|
|
160
|
+
>
|
|
161
|
+
> <details><summary>Not evaluable before apply (1)</summary> … </details>
|
|
162
|
+
|
|
163
|
+
The comment names the **Terraform address** of each resource, which is already in your
|
|
164
|
+
repository (with `for_each` keys hashed), and never a cloud identifier, an account number or a
|
|
165
|
+
value from the plan: a variable is named (`DB_PASSWORD`), its value is not read.
|
|
166
|
+
|
|
167
|
+
### What is checked
|
|
168
|
+
|
|
169
|
+
| Cloud | Terraform resources | Rules |
|
|
170
|
+
| --- | --- | --- |
|
|
171
|
+
| AWS | `aws_security_group`, `aws_default_security_group`, `aws_security_group_rule`, `aws_vpc_security_group_ingress_rule` (+ `aws_ec2_managed_prefix_list`) | Admin ports (22/3389) open to the internet, directly or through a prefix list |
|
|
172
|
+
| AWS | `aws_s3_bucket` + `aws_s3_bucket_public_access_block`, `aws_s3_account_public_access_block`, `aws_s3_bucket_acl`, `aws_s3_bucket_policy` | Public bucket |
|
|
173
|
+
| AWS | `aws_db_instance`, `aws_rds_cluster_instance` (+ `aws_rds_cluster`) | Unencrypted storage |
|
|
174
|
+
| AWS | `aws_lambda_function` + `aws_lambda_function_url`, `aws_lambda_permission` | Invocable from the internet; secret-looking environment variable names |
|
|
175
|
+
| AWS | `aws_cloudtrail` | Trail stopped; management events excluded |
|
|
176
|
+
| AWS | `aws_kms_key` | Key rotation off |
|
|
177
|
+
| AWS | `aws_vpc`, `aws_default_vpc` + `aws_flow_log` (+ `aws_subnet`) | VPC without flow logs; flow logs that miss part of the traffic |
|
|
178
|
+
| Azure | `azurerm_network_security_group`, `azurerm_network_security_rule` | Admin ports open to the internet |
|
|
179
|
+
| Azure | `azurerm_storage_account` | Public blobs allowed; weak TLS or HTTP |
|
|
180
|
+
| Azure | `azurerm_mssql_server` + `azurerm_mssql_firewall_rule` | Public network access open to 0.0.0.0–255.255.255.255; weak TLS |
|
|
181
|
+
| Azure | `azurerm_role_assignment` | Owner or User Access Administrator at any scope; Contributor at subscription scope (a Contributor at a management group or at `/` is not flagged, neither here nor in the live scan) |
|
|
182
|
+
| Google Cloud | `google_compute_firewall` | Admin ports open to the internet |
|
|
183
|
+
| Google Cloud | `google_sql_database_instance` | Authorized from 0.0.0.0/0; no CMEK |
|
|
184
|
+
| Google Cloud | `google_cloudfunctions_function`, `google_cloudfunctions2_function` | Secret-looking environment variable names |
|
|
185
|
+
| Google Cloud | `google_project_iam_member`, `_binding`, `_policy`; `google_service_account_key` | Primitive Owner/Editor role; user-managed key |
|
|
186
|
+
|
|
187
|
+
**Toxic combinations** are reported when the plan holds every part: an unencrypted database with
|
|
188
|
+
a public endpoint, a public function with secrets in its configuration, public SQL or storage with
|
|
189
|
+
weak TLS, an Owner/Editor service account with a downloadable key.
|
|
190
|
+
|
|
191
|
+
### What a plan cannot tell — "not evaluable"
|
|
192
|
+
|
|
193
|
+
A plan is not the cloud. When a rule needs something the plan does not have, the resource is
|
|
194
|
+
listed as **not evaluable, with the reason**, and is never counted as passing:
|
|
195
|
+
|
|
196
|
+
| Reason | Example |
|
|
197
|
+
| --- | --- |
|
|
198
|
+
| `value only known after apply` | A CIDR taken from another resource, an encryption flag set from a variable, Lambda environment variables when any value comes from another resource (Terraform then hides the whole map, names included) |
|
|
199
|
+
| `depends on configuration outside this plan` | An S3 bucket when the account-level public access block is not in the plan; an existing Lambda function whose URL or resource policy are not managed here |
|
|
200
|
+
| `needs the cloud to evaluate it` | A bucket policy open to `*` under a condition; CloudTrail advanced event selectors |
|
|
201
|
+
| `the plan does not carry this setting` | An attribute the adapter does not translate |
|
|
202
|
+
|
|
203
|
+
Defaults are applied only to what the plan **creates**: a new S3 bucket is created with every
|
|
204
|
+
public access block on, a new bucket has no policy unless the plan adds one. An existing resource
|
|
205
|
+
may carry configuration made outside Terraform, so the same absence proves nothing about it — and
|
|
206
|
+
`aws_default_security_group` / `aws_default_vpc` adopt something that already exists, so they are
|
|
207
|
+
never treated as new.
|
|
208
|
+
|
|
209
|
+
**S3 buckets are often "not evaluable".** The account-level public access block decides as much
|
|
210
|
+
as the bucket's own, and it is only read from the plan when `aws_s3_account_public_access_block`
|
|
211
|
+
is in it **and** belongs to the same account: a single `aws` provider configuration, or the same
|
|
212
|
+
provider alias as the bucket. Without it the bucket is not evaluable: the account block could be
|
|
213
|
+
the one that closes it. With it, a bucket is reported as public as soon as one path is proven open
|
|
214
|
+
(a public ACL or a public policy, with both blocks off for that path), even if the other path
|
|
215
|
+
cannot be read. Likewise, a Lambda function is reported as public as soon as one path is proven
|
|
216
|
+
open (a URL without auth, or `*` in its resource policy).
|
|
217
|
+
|
|
218
|
+
Also not checked before deploy, and said so in the comment:
|
|
219
|
+
|
|
220
|
+
- **Account-wide rules** (is there a multi-region trail at all? an Activity Log export? the CIS
|
|
221
|
+
alarm set?) need the whole account and are only checked by the live scan.
|
|
222
|
+
- Resource types with a live rule that the plan check does not translate yet (IAM users and
|
|
223
|
+
access keys, load balancers, Lightsail, snapshots, the legacy `azurerm_sql_server` and its
|
|
224
|
+
firewall rules, GCP disks and firewall policies, log sinks and diagnostic settings) are named
|
|
225
|
+
when they appear in the plan.
|
|
226
|
+
- Something declared on a Lambda alias or version (`qualifier`) is not what the live rule reads,
|
|
227
|
+
and is not counted.
|
|
228
|
+
- A child written as `bucket = each.value.id` is joined to the parent through its `for_each`
|
|
229
|
+
(`for_each = aws_s3_bucket.b`), by instance key. Terraform does not expose the expression, only
|
|
230
|
+
what it references, so a `for_each` that renames the keys so that they still collide with the
|
|
231
|
+
parent's (`{ for k, v in aws_s3_bucket.b : f(k) => v }`) can join the wrong instance.
|
|
232
|
+
- A `dynamic` block (`dynamic "restore_to_point_in_time"`) is not visible in the plan's
|
|
233
|
+
configuration: a restore declared that way is read as absent, which can only add a finding.
|
|
234
|
+
- Public Google Cloud Storage buckets (`google_storage_bucket_iam_*`) have no rule yet, neither
|
|
235
|
+
before nor after deploy.
|
|
236
|
+
- Delivery health (a flow log or a trail that stops writing) only exists in the cloud.
|
|
237
|
+
|
|
238
|
+
### Drift: plan vs. cloud
|
|
239
|
+
|
|
240
|
+
With `--drift-account <accountId>` (the AAA Sentinel account id, `acc_…`) the CLI reads the
|
|
241
|
+
findings of your last scan through the public API (`GET /v1/security/findings`) and lists the
|
|
242
|
+
resources of the plan where the scan has a finding that the plan, for the same rule, does not.
|
|
243
|
+
|
|
244
|
+
What that means depends on what the plan does with the resource. `terraform plan` refreshes
|
|
245
|
+
existing resources first, so drift in what Terraform manages — a port opened by hand on a security
|
|
246
|
+
group whose inline rules live in the code — shows up as an **update** that reverts it:
|
|
247
|
+
|
|
248
|
+
| Shown as | When | Read it as |
|
|
249
|
+
| --- | --- | --- |
|
|
250
|
+
| re-check after apply | The plan changes the resource | The scan saw the problem and the plan changes the resource: the next scan will tell whether it is gone |
|
|
251
|
+
| the scan differs | The plan leaves the resource unchanged | The scan may predate a fix, or the cause is something Terraform does not manage on that resource |
|
|
252
|
+
|
|
253
|
+
Rules whose cause only exists in the cloud (flow log delivery) are left out, and a Lambda function
|
|
254
|
+
or an RDS instance is only matched when the region of the plan resource is known (from its ARN):
|
|
255
|
+
their names are unique only within a region.
|
|
256
|
+
|
|
257
|
+
It needs a **separate** OAuth2 application with the `security:read` scope, in
|
|
258
|
+
`IAC_SECURITY_CLIENT_ID` / `IAC_SECURITY_CLIENT_SECRET`, and the security add-on on that account.
|
|
259
|
+
Keep `iac:estimate` and `security:read` in two applications: the first one is safe in a CI that
|
|
260
|
+
third parties can trigger, the second one reads your findings.
|
|
261
|
+
|
|
262
|
+
Limits, all from what the API exposes:
|
|
263
|
+
|
|
264
|
+
- only findings are published, not resources that pass, so only one direction is reported
|
|
265
|
+
(flagged by the scan, passing in the plan);
|
|
266
|
+
- the API returns the 500 most severe findings of the **last scan**; the comment says when the
|
|
267
|
+
list was truncated;
|
|
268
|
+
- resources are matched by their cloud identifier, which a plan only knows for resources that
|
|
269
|
+
already exist;
|
|
270
|
+
- reading the account directly with your own cloud credentials is not supported: it would bring a
|
|
271
|
+
cloud SDK into this package.
|
|
272
|
+
|
|
273
|
+
> The drift section tells readers of the pull request what your last scan found **in your
|
|
274
|
+
> cloud**. Do not enable it on a public repository.
|
|
275
|
+
|
|
82
276
|
## GitHub Actions
|
|
83
277
|
|
|
84
278
|
Posts one comment per pull request and **updates it in place** on later pushes instead of
|
|
@@ -86,7 +280,7 @@ stacking a new one each time. The token is the one the run already injects — n
|
|
|
86
280
|
token, no app installation.
|
|
87
281
|
|
|
88
282
|
```yaml
|
|
89
|
-
name: Infrastructure cost
|
|
283
|
+
name: Infrastructure cost and security
|
|
90
284
|
on: pull_request
|
|
91
285
|
|
|
92
286
|
permissions:
|
|
@@ -107,7 +301,7 @@ jobs:
|
|
|
107
301
|
- run: terraform plan -out=plan.tfplan
|
|
108
302
|
- run: terraform show -json plan.tfplan > plan.json
|
|
109
303
|
|
|
110
|
-
- name: Estimate cost
|
|
304
|
+
- name: Estimate cost and check security
|
|
111
305
|
env:
|
|
112
306
|
IAC_COST_CLIENT_ID: ${{ secrets.IAC_COST_CLIENT_ID }}
|
|
113
307
|
IAC_COST_CLIENT_SECRET: ${{ secrets.IAC_COST_CLIENT_SECRET }}
|
|
@@ -128,8 +322,16 @@ jobs:
|
|
|
128
322
|
else
|
|
129
323
|
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@comment.md
|
|
130
324
|
fi
|
|
325
|
+
|
|
326
|
+
# Optional: block the merge on critical or high findings. Runs after the comment, so the
|
|
327
|
+
# review shows why it failed. Local only: no credentials, nothing sent.
|
|
328
|
+
- name: Security gate
|
|
329
|
+
run: npx --yes @aaa-sentinel/iac-cost plan.json --no-cost --format text --fail-on high
|
|
131
330
|
```
|
|
132
331
|
|
|
332
|
+
Make the `Security gate` job a **required status check** in the branch protection rules to
|
|
333
|
+
actually block the merge.
|
|
334
|
+
|
|
133
335
|
> `continue-on-error` is not decoration: without it, a GitHub API hiccup while posting the comment
|
|
134
336
|
> would fail the job, and a cost comment must never be able to do that.
|
|
135
337
|
|
|
@@ -148,7 +350,7 @@ pipelines:
|
|
|
148
350
|
pull-requests:
|
|
149
351
|
'**':
|
|
150
352
|
- step:
|
|
151
|
-
name: Infrastructure cost
|
|
353
|
+
name: Infrastructure cost and security
|
|
152
354
|
image: hashicorp/terraform:latest
|
|
153
355
|
script:
|
|
154
356
|
- apk add --no-cache nodejs npm curl jq
|
|
@@ -157,32 +359,191 @@ pipelines:
|
|
|
157
359
|
- terraform show -json plan.tfplan > plan.json
|
|
158
360
|
- npx --yes @aaa-sentinel/iac-cost plan.json > comment.md
|
|
159
361
|
- |
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
-
|
|
172
|
-
|
|
173
|
-
|
|
362
|
+
(
|
|
363
|
+
set +e
|
|
364
|
+
[ -s comment.md ] || exit 0
|
|
365
|
+
API="https://api.bitbucket.org/2.0/repositories/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG/pullrequests/$BITBUCKET_PR_ID/comments"
|
|
366
|
+
BODY=$(jq -Rs . < comment.md)
|
|
367
|
+
ID=$(curl -sf -H "Authorization: Bearer $BB_TOKEN" "$API?pagelen=100" \
|
|
368
|
+
| jq -r '[.values[] | select((.content.raw // "") | contains("<!-- aaa-sentinel:iac-cost -->"))][0].id // empty')
|
|
369
|
+
if [ -n "$ID" ]; then
|
|
370
|
+
curl -sf -X PUT -H "Authorization: Bearer $BB_TOKEN" -H 'Content-Type: application/json' \
|
|
371
|
+
-d "{\"content\":{\"raw\":$BODY}}" "$API/$ID"
|
|
372
|
+
else
|
|
373
|
+
curl -sf -X POST -H "Authorization: Bearer $BB_TOKEN" -H 'Content-Type: application/json' \
|
|
374
|
+
-d "{\"content\":{\"raw\":$BODY}}" "$API"
|
|
375
|
+
fi
|
|
376
|
+
exit 0
|
|
377
|
+
) || true
|
|
378
|
+
# Optional gate, after the comment so the review shows why: fails on critical or high findings.
|
|
379
|
+
- npx --yes @aaa-sentinel/iac-cost plan.json --no-cost --format text --fail-on high
|
|
174
380
|
```
|
|
175
381
|
|
|
176
|
-
> The `set +e
|
|
177
|
-
> GitHub: a failure while commenting must not fail the pipeline
|
|
382
|
+
> The `set +e`, the subshell and the final `exit 0` are there for the same reason as
|
|
383
|
+
> `continue-on-error` on GitHub: a failure while commenting must not fail the pipeline, and the
|
|
384
|
+
> `exit` must not end the script before the optional gate. A deleted comment has a null
|
|
178
385
|
> `content.raw`, hence the `// ""` — without it `jq` aborts and the comment is posted again
|
|
179
386
|
> instead of updated.
|
|
180
387
|
|
|
388
|
+
## Fixes as pull requests
|
|
389
|
+
|
|
390
|
+
If a resource is managed by Terraform, fixing it in the console creates drift: the next
|
|
391
|
+
`terraform apply` puts the problem back. `iac-cost fix` turns the open findings of your last scan
|
|
392
|
+
into pull requests against the code that manages each resource, and tells AAA Sentinel, so the app
|
|
393
|
+
shows the pull request and stops offering the console change for that finding while a pull request
|
|
394
|
+
is open or merged, or about to be opened. A suggestion without pull request, a closed pull request or
|
|
395
|
+
a public repository leave the console change available.
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
terraform plan -out=plan.tfplan
|
|
399
|
+
terraform show -json plan.tfplan > plan.json
|
|
400
|
+
npx @aaa-sentinel/iac-cost fix plan.json --account acc_…
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Run it on the branch you deploy from (a push to `main`, a schedule), not on a pull request: the
|
|
404
|
+
pull requests target that branch, and the plan has to describe what is deployed. For each open
|
|
405
|
+
finding (the 500 most severe, accepted risks excluded) it:
|
|
406
|
+
|
|
407
|
+
1. finds the resource of the finding in the plan by its cloud id, like the [drift check](#drift-plan-vs-cloud),
|
|
408
|
+
and checks that the plan puts it in the same AWS account, GCP project or Azure subscription as the
|
|
409
|
+
AAA Sentinel account. Findings on resources that are not in this plan are left alone: they belong
|
|
410
|
+
to other code;
|
|
411
|
+
2. finds its `resource` block in the `.tf` files of its module directory (following local module
|
|
412
|
+
`source`s), and edits **only the attribute that causes the finding**, and only if its value is a
|
|
413
|
+
literal written in that block;
|
|
414
|
+
3. commits the change to a new branch `aaa-sentinel/fix-<id>` and opens one pull request per
|
|
415
|
+
finding, at most `--max-prs` (default 5) per run;
|
|
416
|
+
4. records the result in AAA Sentinel. On later runs it updates the state of its pull requests
|
|
417
|
+
(open, merged, closed) instead of opening new ones. A closed pull request is not reopened; a
|
|
418
|
+
merged one whose change is no longer in the code gets a new pull request on a new branch; a branch
|
|
419
|
+
of its own left without a pull request (the token could not open it yet) gets one.
|
|
420
|
+
|
|
421
|
+
Before committing, it checks that each file in the CI checkout is the file at the deployed commit,
|
|
422
|
+
ignoring only a byte order mark and line endings: if an earlier step changed it (generated files,
|
|
423
|
+
substituted secrets), the fix stays pending with `working_copy_changed`. The commit keeps the BOM
|
|
424
|
+
and line endings of the file at the deployed commit, not those of the runner's checkout. On a **public
|
|
425
|
+
repository** it opens nothing unless you pass `--allow-public-repo`: the pull request would tell
|
|
426
|
+
anyone where your live finding is.
|
|
427
|
+
|
|
428
|
+
| Rule | Change in the code |
|
|
429
|
+
| --- | --- |
|
|
430
|
+
| `SEC_AWS_NETWORK_ADMIN_ABIERTO` | Removes `0.0.0.0/0` / `::/0` from the rule that opens **only** port 22 or 3389 (inline `ingress`, `aws_security_group_rule`, `aws_vpc_security_group_ingress_rule`); removes the rule if it has no other source, and writes `ingress = []` when the last inline rule goes, so Terraform deletes it instead of leaving it unmanaged |
|
|
431
|
+
| `SEC_AWS_EXPOSURE_S3_PUBLICO` | Sets the four flags of the bucket's `aws_s3_bucket_public_access_block` to `true` |
|
|
432
|
+
| `SEC_AWS_ENCRYPTION_KMS_SIN_ROTACION` | `enable_key_rotation = true` |
|
|
433
|
+
| `SEC_AWS_EXPOSURE_LAMBDA_PUBLICA` | `authorization_type = "AWS_IAM"` on the function URL (callers then have to sign requests) |
|
|
434
|
+
| `SEC_AZURE_EXPOSURE_BLOB_PUBLICO` | `allow_nested_items_to_be_public = false` (`allow_blob_public_access` on azurerm 2.x) |
|
|
435
|
+
| `SEC_AZURE_ENCRYPTION_STORAGE_TLS` | `min_tls_version = "TLS1_2"` and HTTPS only, where the code sets them weaker |
|
|
436
|
+
| `SEC_AZURE_ENCRYPTION_SQL_TLS` | `minimum_tls_version = "1.2"` (also when the code says `"Disabled"`, which azurerm 3.x accepts and means no minimum) |
|
|
437
|
+
| `SEC_GCP_NETWORK_ADMIN_ABIERTO` | Removes `0.0.0.0/0` from `source_ranges` of a firewall that allows only 22/3389, when another range, tag or service account remains |
|
|
438
|
+
|
|
439
|
+
Everything else is recorded as a **suggested change in the code, without a pull request**, with
|
|
440
|
+
the reason, and the app shows it that way:
|
|
441
|
+
|
|
442
|
+
| Reason | Why it is not edited |
|
|
443
|
+
| --- | --- |
|
|
444
|
+
| `value_from_expression` | The value comes from a variable, a local, a function or a reference: the same code may serve another environment with another value |
|
|
445
|
+
| `module_not_local` | The resource is in a module from the registry or git, not in this repository |
|
|
446
|
+
| `multiple_instances` | `count` / `for_each` on the resource or its module, or a local module called more than once: the block is shared by several instances |
|
|
447
|
+
| `dynamic_block` | The rules come from a `dynamic` block |
|
|
448
|
+
| `other_ports` | The open rule also opens other ports (or all of them): closing it would cut that traffic too |
|
|
449
|
+
| `only_source` | The internet range is the only source; on GCP, a firewall without sources applies to `0.0.0.0/0` |
|
|
450
|
+
| `no_public_access_block` | The bucket has no `aws_s3_bucket_public_access_block` to edit |
|
|
451
|
+
| `replacement_required` | The fix forces a new resource (RDS encryption, Cloud SQL CMEK) |
|
|
452
|
+
| `ignore_changes` | `lifecycle { ignore_changes }` covers the attribute (or has a comment inside the list): Terraform would not apply the change |
|
|
453
|
+
| `unsupported_host` | GitHub Enterprise Server (`GITHUB_API_URL` is not `https://api.github.com`): not supported yet, the app only links pull requests on github.com and bitbucket.org |
|
|
454
|
+
| `cause_not_in_code` | The open value is not in the code (added by hand, or a provider default) |
|
|
455
|
+
| `block_not_found` | The `resource` block could not be read (`.tf.json`, or a layout the editor does not handle) |
|
|
456
|
+
| `rule_not_automated` | No deterministic code fix for that rule |
|
|
457
|
+
|
|
458
|
+
The rest of the file is kept byte for byte, comments and line endings included (a removed block takes
|
|
459
|
+
the comment lines right above it, up to the first blank line); run `terraform fmt`
|
|
460
|
+
in review if the alignment of `=` matters to you. The pull request says what changed and why;
|
|
461
|
+
review its `terraform plan` before merging. The finding closes after `terraform apply` and the next scan.
|
|
462
|
+
|
|
463
|
+
| Option | Meaning |
|
|
464
|
+
| --- | --- |
|
|
465
|
+
| `--account <accountId>` | AAA Sentinel account id. Also `IAC_FIX_ACCOUNT_ID` |
|
|
466
|
+
| `--dir <path>` | Directory where `terraform plan` ran (default: current). The repository root is the nearest `.git` above it |
|
|
467
|
+
| `--max-prs <n>` | Pull requests opened per run (default 5, `0` records without opening) |
|
|
468
|
+
| `--host <github\|bitbucket>` | Git host. Detected from the CI by default |
|
|
469
|
+
| `--dry-run` | Prints the changes; opens nothing and records nothing. Works without a git host |
|
|
470
|
+
| `--allow-public-repo` | Opens pull requests on a public repository too |
|
|
471
|
+
| `--format <text\|json>` | Output format |
|
|
472
|
+
|
|
473
|
+
Exit code: `2` for a usage error (no account, no git host, the run is a pull request build, the input
|
|
474
|
+
is not a plan); otherwise `0`, also when the API or the git host fails — the step prints a warning
|
|
475
|
+
and the findings stay `pending`.
|
|
476
|
+
|
|
477
|
+
### GitHub Actions
|
|
478
|
+
|
|
479
|
+
The workflow token can open the pull request with `contents: write` and `pull-requests: write`. In
|
|
480
|
+
the repository (or organization) settings, **Actions → General → Workflow permissions** must allow
|
|
481
|
+
*GitHub Actions to create and approve pull requests*. Pull requests opened with that token do not
|
|
482
|
+
trigger other workflows (a GitHub rule), so the cost and security comment will not run on them
|
|
483
|
+
until someone pushes to the branch.
|
|
484
|
+
|
|
485
|
+
```yaml
|
|
486
|
+
name: Security fixes
|
|
487
|
+
on:
|
|
488
|
+
push:
|
|
489
|
+
branches: [main]
|
|
490
|
+
schedule:
|
|
491
|
+
- cron: '0 7 * * 1-5'
|
|
492
|
+
|
|
493
|
+
permissions:
|
|
494
|
+
contents: write
|
|
495
|
+
pull-requests: write
|
|
496
|
+
|
|
497
|
+
jobs:
|
|
498
|
+
fix:
|
|
499
|
+
runs-on: ubuntu-latest
|
|
500
|
+
steps:
|
|
501
|
+
- uses: actions/checkout@v4
|
|
502
|
+
- uses: hashicorp/setup-terraform@v3
|
|
503
|
+
- uses: actions/setup-node@v4
|
|
504
|
+
with:
|
|
505
|
+
node-version: 20
|
|
506
|
+
- run: terraform init
|
|
507
|
+
- run: terraform plan -out=plan.tfplan
|
|
508
|
+
- run: terraform show -json plan.tfplan > plan.json
|
|
509
|
+
- name: Open security fixes as pull requests
|
|
510
|
+
continue-on-error: true
|
|
511
|
+
env:
|
|
512
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
513
|
+
IAC_FIX_CLIENT_ID: ${{ secrets.IAC_FIX_CLIENT_ID }}
|
|
514
|
+
IAC_FIX_CLIENT_SECRET: ${{ secrets.IAC_FIX_CLIENT_SECRET }}
|
|
515
|
+
run: npx --yes @aaa-sentinel/iac-cost fix plan.json --account ${{ vars.AAA_SENTINEL_ACCOUNT_ID }}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Bitbucket Pipelines
|
|
519
|
+
|
|
520
|
+
Bitbucket Pipelines does not inject a token that can open pull requests. Create a **Repository
|
|
521
|
+
Access Token** with `repository:write` and `pullrequest:write` (the comment step's `BB_TOKEN` works
|
|
522
|
+
if it has both) and store it as a secured variable `BB_TOKEN`.
|
|
523
|
+
|
|
524
|
+
```yaml
|
|
525
|
+
pipelines:
|
|
526
|
+
branches:
|
|
527
|
+
main:
|
|
528
|
+
- step:
|
|
529
|
+
name: Security fixes
|
|
530
|
+
image: hashicorp/terraform:latest
|
|
531
|
+
script:
|
|
532
|
+
- apk add --no-cache nodejs npm
|
|
533
|
+
- terraform init
|
|
534
|
+
- terraform plan -out=plan.tfplan
|
|
535
|
+
- terraform show -json plan.tfplan > plan.json
|
|
536
|
+
- npx --yes @aaa-sentinel/iac-cost fix plan.json --account "$AAA_SENTINEL_ACCOUNT_ID" || true
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
The commit is created through the Bitbucket API (`POST /src`), so the clone needs no push access.
|
|
540
|
+
|
|
181
541
|
## Why there is no comment
|
|
182
542
|
|
|
183
543
|
| What you see | Why |
|
|
184
544
|
| --- | --- |
|
|
185
|
-
| No comment at all | The plan changes no billable resource, or the credentials are missing — check the step log, the reason is always printed |
|
|
545
|
+
| No comment at all | The plan changes no billable resource and has no resource the security rules cover, or the credentials are missing — check the step log, the reason is always printed |
|
|
546
|
+
| A security section but no cost estimate | `IAC_COST_CLIENT_ID`/`IAC_COST_CLIENT_SECRET` are not set: the security check needs no credentials and still runs |
|
|
186
547
|
| A breakdown with no total | Fewer than half of the billable resources could be priced |
|
|
187
548
|
| `region could not be resolved from the plan` | The region comes from a variable or an aliased provider, so the plan does not say which one applies |
|
|
188
549
|
| `provider recognized, pricing not connected yet` | A cloud we support but whose catalogue is not connected yet (Google) |
|