@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.
Files changed (3) hide show
  1. package/README.md +390 -29
  2. package/dist/cli.js +11843 -4089
  3. 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
- - An OAuth2 application with the `iac:estimate` scope, created in the
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
- - A **Pro** or **Business** plan.
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
- ### It never fails your build
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
- The exit code is **always 0**. A missing credential, a network error, an unreadable plan or an
49
- outage on our side prints a one-line warning and stops. A cost comment is not a quality gate, and
50
- our availability must not become a dependency of your deployments.
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
- set +e
161
- [ -s comment.md ] || exit 0
162
- API="https://api.bitbucket.org/2.0/repositories/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG/pullrequests/$BITBUCKET_PR_ID/comments"
163
- BODY=$(jq -Rs . < comment.md)
164
- ID=$(curl -sf -H "Authorization: Bearer $BB_TOKEN" "$API?pagelen=100" \
165
- | jq -r '[.values[] | select((.content.raw // "") | contains("<!-- aaa-sentinel:iac-cost -->"))][0].id // empty')
166
- if [ -n "$ID" ]; then
167
- curl -sf -X PUT -H "Authorization: Bearer $BB_TOKEN" -H 'Content-Type: application/json' \
168
- -d "{\"content\":{\"raw\":$BODY}}" "$API/$ID"
169
- else
170
- curl -sf -X POST -H "Authorization: Bearer $BB_TOKEN" -H 'Content-Type: application/json' \
171
- -d "{\"content\":{\"raw\":$BODY}}" "$API"
172
- fi
173
- exit 0
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` and the final `exit 0` are there for the same reason as `continue-on-error` on
177
- > GitHub: a failure while commenting must not fail the pipeline. A deleted comment has a null
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) |