Skip to content

Commit 71e6966

Browse files
herosjourneyLogan Kleier
andauthored
feat(aws-startup-advisor): Heroku-to-AWS day-to-day command cheat sheet in the heroku-to-aws migration guide (#615)
* feat(aws-startup-advisor): add a Heroku-to-AWS day-to-day command cheat sheet to the heroku-to-aws migration guide The guide covered provisioning, data, cutover and rollback and then stopped; nothing told developers what `heroku logs --tail`, `run bash`, `config:set`, `ps:scale`, `releases`, `rollback`, `maintenance:on` or `pg:psql` become. - MIGRATION_GUIDE.md: new "Day-to-day commands: Heroku -> AWS" section (TOC entry; one table per compute target present, rows filled with the app's generated names so they paste). Closing paragraph names the two commands with no one-liner (push-to-deploy -> Phase 3 workflow; pipelines:promote -> not generated) and suggests bin/ wrappers as a first platform-team task. - ecs.tf: enable_execute_command = true on each service plus the ssmmessages actions on the task role, so the `heroku run bash` row (ECS Exec) is true. - Template Variable Resolution: EB_WEB_ENVIRONMENT and ECS_CLUSTER. * fix(aws-startup-advisor): render the Heroku command cheat sheet from the design and gate ECS Exec honestly Review findings on PR #615. - validate-terraform-policy.py: no_wildcard_iam now exempts a second statement shape, an Allow whose Action list holds only the four ssmmessages channel actions (ECS Exec), because AWS defines no resource-level permissions for them. The exemption is statement-shaped: any other action in the same list (ssmmessages:*, ssm:*, s3:*) or a second wildcard statement in the same policy is still rejected. Rule docs (security-posture-rules.md, SKILL.md) updated; tests cover the emitted generate-terraform.md statement passing and the mixed cases failing. - generate-docs.md: the "Day-to-day commands" section now sits before the deferred_addons guard so it renders with an empty deferred list. Rows render per process type from eb_processes[] / fargate_processes[] (no hardcoded -web service, task family or container); maintenance and open rows are gated on app_has_eb_web / app_has_fargate_web; app-wide actions loop over every environment/service. Config rows follow the actual backing store (plain property, Secrets Manager ARN, SSM parameter) with explicit identifiers taken from beanstalk.tf/ecs.tf instead of an invented /<app>/KEY path or <app>/DATABASE_URL secret. EB log tail uses the full .../eb-current-app/stdouterr.log group; shells use /bin/sh (Alpine images); pg rows are gated on has_postgres and branch RDS create-db-snapshot vs Aurora create-db-cluster-snapshot; open uses https://\<custom domain> when configured, else http://\<EB CNAME> (the default environment has no HTTPS listener). - generate-terraform.md: beanstalk.tf enables aws:elasticbeanstalk:cloudwatch:logs StreamLogs=true with RetentionInDays so the tailed group exists. - tests/test_heroku_command_cheatsheet.py pins all of the above. Verification: `mise run ci` passed (lint:md, lint:artifacts, lint:vendored-parity, lint:skill-refs, pytest 1483 passed, telemetry 311 passed); skills/tf-best-practices/scripts/test_validate_terraform_policy.py 63 passed; the extracted ecs_task_exec block returns POLICY_OK. * fix(aws-startup-advisor): make the Heroku command cheat sheet true for the generated stack Review findings on PR #615. - beanstalk.tf attaches AmazonSSMManagedInstanceCore to the EB instance role; the `run bash` / one-off rows use `aws ssm start-session` against the environment's instance plus `docker exec`, and `eb ssh` is dropped (no EC2KeyName, no :22 ingress; AWSElasticBeanstalkWebTier has no ssm). - config:set is split into "existing key" and "new key" on both targets. A new key is a silent no-op until Terraform binds it: EB injects only environmentsecrets names and its role reads only parameter/<project>/* and secret:<project>/*; the Fargate task definition emits no secrets block and the execution role has no SSM / Secrets Manager read. The rows name the {{PROJECT_NAME}} prefix and the Terraform change. - EB rows use {{EB_APPLICATION}} (var.project_name) instead of the Heroku app name; `ps` stays on describe-environments (basic health). - ps:scale / restart rows note that the next apply reverts a CLI scale, point at terraform.tfvars for a persistent change, and forbid scaling or restarting while writers_quiesced = true. - pg:psql reads {{DB_CREDENTIALS_SECRET}} ({username,password} JSON) with jq into PGPASSWORD and connects with sslmode=verify-full; falls back to db_username / db_password in terraform.tfvars when RDS Proxy is absent. - Fargate rollback is by image digest via container_image_* in terraform.tfvars (Phase 3 pushes :latest with --force-new-deployment, so there is no previous tag); the releases row lists imageDigest. - compute.tf comment points at aws_iam_role_policy.ecs_task_exec in security.tf (Step 5) instead of "below". - The closing paragraph no longer depends on the "Developer workflow" report section from another PR. - New good-heroku-fargate-ecs-exec policy fixture (count 31 -> 32); the cheat-sheet test file pins every row above. Verification: `mise run ci` passed (lint:md 0 errors, lint:artifacts, lint:vendored-parity, lint:skill-refs, pytest 1490 passed + 225 subtests, telemetry 311 passed); test_validate_terraform_policy.py and test_heroku_command_cheatsheet.py 78 passed together. * fix(aws-startup-advisor): close the second review round on the Heroku command cheat sheet Review findings on PR #615. - validate-terraform-policy.py: the ssmmessages exemption now requires every Action list member to be a quoted literal from the allowlist; an expression member (local.x, var.y, function call) keeps the statement under no_wildcard_iam. Rule docs updated (SKILL.md, security-posture-rules.md). - generate-docs.md cheat sheet: Fargate rows point at terraform/compute.tf (never ecs.tf / alb.tf); pg:psql reads the db-credentials secret on the operator workstation (neither session role can read it) with POSIX-portable `jq --argjson` instead of a bash here-string, and the no-RDS-Proxy fallback stays explicit; `open` resolves the rendered app's own hostname through dns_hostnames[].heroku_app ({{APP_HTTPS_HOSTNAME}}) instead of the global first custom domain; EB maintenance:on branches on the actual load balancer (Classic default outside has_route53_dns -> maintenance application version; ALB -> name -> ARN -> listeners). - beanstalk.tf RetentionInDays is mapped to the smallest EB-accepted value >= operational.log_retention_days (documented in the template, Q13 and the assemble checklist so reused preferences are validated the same way). Tests: test_ssmmessages_exception_requires_every_member_to_be_a_literal; test_terraform_file_references_match_the_emitted_inventory; test_pg_psql_credential_path_is_the_operator_not_the_session_role (dash -n); test_eb_maintenance_row_branches_on_the_actual_load_balancer_type; test_open_hostname_is_the_rendered_apps_own_dns_hostname; test_eb_retention_is_mapped_to_the_accepted_domain. Verified: `mise run ci` passed (lint:md 0 errors, lint:artifacts, lint:vendored-parity, lint:skill-refs, pytest 1495 passed + 225 subtests, telemetry 311 passed). * fix(aws-startup-advisor): Review findings on PR #615 Replace AmazonSSMManagedInstanceCore with a Session Manager inline policy so parameter reads stay under the project prefix; switch every forwarding EB ALB listener for maintenance mode; fetch the RDS CA bundle on the psql host in both pg:psql rows. Verified: mise run ci passed (1497 tests). --------- Co-authored-by: Logan Kleier <lkleier@amazon.com>
1 parent ac89c65 commit 71e6966

10 files changed

Lines changed: 980 additions & 13 deletions

File tree

‎plugins/aws-startup-advisor/skills/heroku-to-aws/references/phases/clarify/clarify-assemble.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ Before handing off to Design:
137137
- [ ] If peering detected and VPC ID needed → `network.existing_vpc_id` is populated
138138
- [ ] If Fir apps detected → `global.fir_intent` is populated (not null)
139139
- [ ] `operational.containerization_status` is populated
140+
- [ ] `operational.log_retention_days`, when present (fresh or reused preferences alike), is an integer 1–3653; Generate maps it to the Elastic Beanstalk `RetentionInDays` domain (smallest accepted value >= the preference — `clarify-interview.md` Q13), so a value outside 1–3653 is rejected here, not rounded
140141
- [ ] `design_constraints.compute_target.default` is one of: `"elastic_beanstalk"`, `"ecs-fargate"`, `"eks-managed"`, `"eks-or-ecs"`
141142
- [ ] `design_constraints.compute_target.chosen_by` is one of: `"user"`, `"system_recommended"`, `"default"`
142143
- [ ] `design_constraints.compute_target.overrides[]` entries, when present, have `formation`, `value`, `reason`, and `chosen_by`

‎plugins/aws-startup-advisor/skills/heroku-to-aws/references/phases/clarify/clarify-interview.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,12 @@ Validate: must be valid ISO 8601 date, must be in the future.
801801

802802
**Default:** 3 → `log_retention_days: 30`
803803

804+
Any value 1–3653 is stored as given. Elastic Beanstalk's `aws:elasticbeanstalk:cloudwatch:logs`
805+
`RetentionInDays` accepts only 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731,
806+
1827, 3653, so for a custom value Generate writes the smallest accepted value that is >= the
807+
preference (2 → 3, 45 → 60); choices 1–5 are accepted as-is. Tell the user when their custom
808+
value will be rounded up this way.
809+
804810
---
805811

806812
#### Q14 — Alerting Preference

‎plugins/aws-startup-advisor/skills/heroku-to-aws/references/phases/generate/generate-docs.md‎

Lines changed: 124 additions & 0 deletions
Large diffs are not rendered by default.

‎plugins/aws-startup-advisor/skills/heroku-to-aws/references/phases/generate/generate-terraform.md‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,25 @@ resource "aws_iam_role" "ecs_task" {
10561056
Name = "${var.project_name}-${var.environment}-ecs-task"
10571057
}
10581058
}
1059+
1060+
# ECS Exec (the `heroku run bash` equivalent) opens an SSM channel from the task.
1061+
resource "aws_iam_role_policy" "ecs_task_exec" {
1062+
name = "${var.project_name}-${var.environment}-ecs-exec"
1063+
role = aws_iam_role.ecs_task.id
1064+
policy = jsonencode({
1065+
Version = "2012-10-17"
1066+
Statement = [{
1067+
Effect = "Allow"
1068+
Action = [
1069+
"ssmmessages:CreateControlChannel",
1070+
"ssmmessages:CreateDataChannel",
1071+
"ssmmessages:OpenControlChannel",
1072+
"ssmmessages:OpenDataChannel"
1073+
]
1074+
Resource = "*"
1075+
}]
1076+
})
1077+
}
10591078
```
10601079

10611080
---
@@ -1157,6 +1176,10 @@ resource "aws_ecs_service" "<app_sanitized>_<process_type>" {
11571176
name = "${var.project_name}-${var.environment}-<process_type>"
11581177
cluster = aws_ecs_cluster.main.id
11591178
task_definition = aws_ecs_task_definition.<app_sanitized>_<process_type>.arn
1179+
# ECS Exec is the `heroku run bash` equivalent (MIGRATION_GUIDE.md § Day-to-day commands).
1180+
# It needs the ssmmessages permissions that aws_iam_role_policy.ecs_task_exec grants
1181+
# the task role in security.tf (Step 5).
1182+
enable_execute_command = true
11601183
# 0 while var.writers_quiesced is true, so a later apply (DNS rollback, a repair)
11611184
# cannot restart tasks that Phase 6 scaled to zero. Set the variable back to false
11621185
# only at the next intentional handoff.
@@ -1422,6 +1445,27 @@ resource "aws_elastic_beanstalk_environment" "<app_name>_<process_type>" {
14221445
value = var.eb_deployment_policy
14231446
}
14241447
1448+
# Stream instance logs to CloudWatch Logs so `aws logs tail` works (the
1449+
# `heroku logs --tail` equivalent in MIGRATION_GUIDE.md § Day-to-day commands).
1450+
# The Docker application log lands in
1451+
# /aws/elasticbeanstalk/<environment>/var/log/eb-docker/containers/eb-current-app/stdouterr.log
1452+
setting {
1453+
namespace = "aws:elasticbeanstalk:cloudwatch:logs"
1454+
name = "StreamLogs"
1455+
value = "true"
1456+
}
1457+
1458+
# RetentionInDays accepts only 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400,
1459+
# 545, 731, 1827, 3653. Clarify Q13 allows any integer 1–3653 (including a reused
1460+
# preferences.json), so the generator emits the smallest accepted value that is
1461+
# >= operational.log_retention_days (2 → 3, 45 → 60, 30 → 30; default 30). Never copy
1462+
# the raw preference: an unlisted value fails environment creation/update.
1463+
setting {
1464+
namespace = "aws:elasticbeanstalk:cloudwatch:logs"
1465+
name = "RetentionInDays"
1466+
value = "<smallest EB-accepted RetentionInDays >= preferences.json.operational.log_retention_days || 30>"
1467+
}
1468+
14251469
# {{IF process_type == "web"}}
14261470
setting {
14271471
namespace = "aws:elasticbeanstalk:application:environment"
@@ -1467,6 +1511,34 @@ resource "aws_iam_role_policy_attachment" "eb_web_tier_<app_name>" {
14671511
policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
14681512
}
14691513
1514+
# Session Manager access for `aws ssm start-session` (the `heroku run bash` equivalent in
1515+
# MIGRATION_GUIDE.md § Day-to-day commands). The environment has no EC2 key pair and no
1516+
# :22 ingress, so `eb ssh` cannot reach an instance; AWSElasticBeanstalkWebTier grants no
1517+
# ssm:*/ssmmessages:* either. The SSM agent is preinstalled on the AL2023 Docker platform.
1518+
# Do NOT attach the AmazonSSMManagedInstanceCore managed policy here: it also allows
1519+
# ssm:GetParameter(s) on Resource "*", which would widen the parameter-read boundary that
1520+
# eb_read_secrets_<app_name> keeps under parameter/<project>/*. This inline policy carries
1521+
# only the actions the agent needs to register and open a session channel.
1522+
resource "aws_iam_role_policy" "eb_ssm_<app_name>" {
1523+
name = "${var.project_name}-eb-session-manager"
1524+
role = aws_iam_role.eb_instance_<app_name>.id
1525+
1526+
policy = jsonencode({
1527+
Version = "2012-10-17"
1528+
Statement = [{
1529+
Effect = "Allow"
1530+
Action = [
1531+
"ssm:UpdateInstanceInformation",
1532+
"ssmmessages:CreateControlChannel",
1533+
"ssmmessages:CreateDataChannel",
1534+
"ssmmessages:OpenControlChannel",
1535+
"ssmmessages:OpenDataChannel"
1536+
]
1537+
Resource = "*"
1538+
}]
1539+
})
1540+
}
1541+
14701542
resource "aws_iam_role_policy" "eb_read_secrets_<app_name>" {
14711543
name = "${var.project_name}-eb-read-secrets"
14721544
role = aws_iam_role.eb_instance_<app_name>.id

‎plugins/aws-startup-advisor/skills/tf-best-practices/SKILL.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,12 @@ for the caller, so a false positive would block a real migration).
223223
`aws_iam_user_policy`):
224224

225225
- **`no_wildcard_iam`** — an `Allow` statement must not use `Action`/`Resource` `"*"`.
226-
The one narrow exception is an isolated
227-
`elasticbeanstalk:CreateStorageLocation` statement with `Resource = "*"` because
228-
AWS does not support resource-level permissions for that action.
226+
Two narrow exceptions, for actions AWS defines no resource-level permissions for:
227+
an isolated `elasticbeanstalk:CreateStorageLocation` statement, and an ECS Exec
228+
statement listing only the four `ssmmessages` channel actions
229+
(`CreateControlChannel`, `CreateDataChannel`, `OpenControlChannel`, `OpenDataChannel`)
230+
as string literals. Any other action, or any non-literal member (`local.x`, `var.y`),
231+
in the same statement disqualifies it.
229232
`aws_iam_policy_document` data sources and assume-role trust policies fail open.
230233

231234
> The checker is a zero-dependency static HCL reader (no `terraform init`, no provider
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Compliant fixture — the ECS Exec task-role grant heroku-to-aws emits in security.tf
2+
# (the `heroku run bash` equivalent). MUST pass (POLICY_OK): AWS defines no resource-level
3+
# permissions for the four ssmmessages channel actions, so Resource "*" is the only
4+
# possible shape and no_wildcard_iam exempts a statement that lists ONLY those actions.
5+
resource "aws_iam_role" "ecs_task" {
6+
name = "${var.project_name}-${var.environment}-ecs-task"
7+
8+
assume_role_policy = jsonencode({
9+
Version = "2012-10-17"
10+
Statement = [{
11+
Action = "sts:AssumeRole"
12+
Effect = "Allow"
13+
Principal = {
14+
Service = "ecs-tasks.amazonaws.com"
15+
}
16+
}]
17+
})
18+
}
19+
20+
resource "aws_iam_role_policy" "ecs_task_exec" {
21+
name = "${var.project_name}-${var.environment}-ecs-exec"
22+
role = aws_iam_role.ecs_task.id
23+
policy = jsonencode({
24+
Version = "2012-10-17"
25+
Statement = [{
26+
Effect = "Allow"
27+
Action = [
28+
"ssmmessages:CreateControlChannel",
29+
"ssmmessages:CreateDataChannel",
30+
"ssmmessages:OpenControlChannel",
31+
"ssmmessages:OpenDataChannel"
32+
]
33+
Resource = "*"
34+
}]
35+
})
36+
}
37+
38+
# A scoped statement next to it must not be disturbed by the exemption.
39+
resource "aws_iam_role_policy" "ecs_task_queue" {
40+
name = "${var.project_name}-${var.environment}-ecs-queue"
41+
role = aws_iam_role.ecs_task.id
42+
policy = jsonencode({
43+
Version = "2012-10-17"
44+
Statement = [{
45+
Effect = "Allow"
46+
Action = ["sqs:SendMessage", "sqs:ReceiveMessage"]
47+
Resource = "arn:aws:sqs:us-east-1:123456789012:${var.project_name}-jobs"
48+
}]
49+
})
50+
}

‎plugins/aws-startup-advisor/skills/tf-best-practices/references/security-posture-rules.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,18 @@ either string form (`"*"`) or single-element list form (`["*"]`). Scope to speci
188188
and resource ARNs. A list that also contains scoped entries (e.g. `["s3:GetObject", ...]`) is
189189
not a blanket wildcard and is allowed.
190190

191-
The narrow exception is an isolated `elasticbeanstalk:CreateStorageLocation`
192-
statement with `Resource = "*"`. AWS does not define resource-level permissions
193-
for that action; combining it with any other action does not qualify for the
194-
exception.
191+
Two narrow exceptions exist, both for actions that AWS defines no resource-level
192+
permissions for, so `Resource = "*"` is the only way to write them:
193+
194+
- an isolated `elasticbeanstalk:CreateStorageLocation` statement;
195+
- an ECS Exec statement whose `Action` list contains only the four `ssmmessages` channel
196+
actions (`CreateControlChannel`, `CreateDataChannel`, `OpenControlChannel`,
197+
`OpenDataChannel`) — the task-role grant behind `aws ecs execute-command`.
198+
199+
The exception is statement-shaped and literal-only: combining either with any other action
200+
(for example `ssmmessages:*`, `ssm:*`, or `s3:*` in the same list) does not qualify, and
201+
neither does a list with a non-literal member (`local.extra_action`, `var.x`, a function
202+
call) — the checker cannot see what it resolves to, so that statement is flagged.
195203

196204
**Gate mapping:** `no_wildcard_iam`. The gate scans literal policy JSON (heredoc or
197205
`jsonencode({...})`) in the resources above. `aws_iam_policy_document` **data sources** fail

‎plugins/aws-startup-advisor/skills/tf-best-practices/scripts/test_validate_terraform_policy.py‎

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,129 @@ def test_required_wildcard_action_cannot_hide_other_actions() -> None:
138138
assert "no_wildcard_iam" in out
139139

140140

141+
def _heroku_ecs_exec_policy_block() -> str:
142+
"""The ECS Exec task-role statement exactly as generate-terraform.md emits it."""
143+
fragment = (
144+
PLUGIN_SKILL_ROOT.parent
145+
/ "heroku-to-aws"
146+
/ "references"
147+
/ "phases"
148+
/ "generate"
149+
/ "generate-terraform.md"
150+
).read_text()
151+
start = fragment.index('resource "aws_iam_role_policy" "ecs_task_exec"')
152+
end = fragment.index("\n}\n", start) + 3
153+
return fragment[start:end]
154+
155+
156+
def test_heroku_ecs_exec_emitted_statement_passes() -> None:
157+
# AWS defines no resource-level permissions for ssmmessages:*; the emitted
158+
# statement lists only the four channel actions, so it is a required grant.
159+
block = _heroku_ecs_exec_policy_block()
160+
for action in (
161+
"ssmmessages:CreateControlChannel",
162+
"ssmmessages:CreateDataChannel",
163+
"ssmmessages:OpenControlChannel",
164+
"ssmmessages:OpenDataChannel",
165+
):
166+
assert action in block
167+
with tempfile.TemporaryDirectory() as tmp:
168+
terraform_dir = Path(tmp)
169+
(terraform_dir / "ecs-exec.tf").write_text(block)
170+
code, out = run_policy_validator(terraform_dir)
171+
assert code == 0, out
172+
assert "POLICY_OK" in out
173+
174+
175+
def test_ssmmessages_exception_cannot_hide_other_actions() -> None:
176+
# The exception is statement-shaped: adding any non-channel action (here the
177+
# service wildcard and an unrelated service) to the list must still fail.
178+
for extra in ('"ssmmessages:*"', '"ssm:StartSession"', '"s3:*"'):
179+
with tempfile.TemporaryDirectory() as tmp:
180+
terraform_dir = Path(tmp)
181+
(terraform_dir / "policy.tf").write_text(
182+
f'''
183+
resource "aws_iam_role_policy" "mixed" {{
184+
role = "example-role"
185+
policy = jsonencode({{
186+
Statement = [{{
187+
Effect = "Allow"
188+
Action = [
189+
"ssmmessages:CreateControlChannel",
190+
"ssmmessages:CreateDataChannel",
191+
"ssmmessages:OpenControlChannel",
192+
"ssmmessages:OpenDataChannel",
193+
{extra}
194+
]
195+
Resource = "*"
196+
}}]
197+
}})
198+
}}
199+
'''
200+
)
201+
code, out = run_policy_validator(terraform_dir)
202+
assert code == 1, (extra, out)
203+
assert "no_wildcard_iam" in out, (extra, out)
204+
205+
206+
def test_ssmmessages_exception_requires_every_member_to_be_a_literal() -> None:
207+
# A non-literal list member (local/var/function) may resolve to anything —
208+
# here `local.extra_action = "s3:*"` — so the statement must keep failing
209+
# no_wildcard_iam even though every quoted literal in the list is allowlisted.
210+
for extra in ("local.extra_action", "var.extra_action", 'format("%s", "s3:*")'):
211+
with tempfile.TemporaryDirectory() as tmp:
212+
terraform_dir = Path(tmp)
213+
(terraform_dir / "policy.tf").write_text(
214+
f'''
215+
locals {{ extra_action = "s3:*" }}
216+
resource "aws_iam_role_policy" "mixed_expression" {{
217+
role = "example-role"
218+
policy = jsonencode({{
219+
Statement = [{{
220+
Effect = "Allow"
221+
Action = ["ssmmessages:CreateControlChannel", {extra}]
222+
Resource = "*"
223+
}}]
224+
}})
225+
}}
226+
'''
227+
)
228+
code, out = run_policy_validator(terraform_dir)
229+
assert code == 1, (extra, out)
230+
assert "no_wildcard_iam" in out, (extra, out)
231+
232+
233+
def test_unrelated_wildcard_list_still_fails_alongside_ecs_exec() -> None:
234+
# A second, over-broad statement in the same policy is still flagged even
235+
# when the approved ECS Exec statement sits next to it.
236+
with tempfile.TemporaryDirectory() as tmp:
237+
terraform_dir = Path(tmp)
238+
(terraform_dir / "policy.tf").write_text(
239+
'''
240+
resource "aws_iam_role_policy" "two_statements" {
241+
role = "example-role"
242+
policy = jsonencode({
243+
Statement = [
244+
{
245+
Effect = "Allow"
246+
Action = ["ssmmessages:CreateControlChannel", "ssmmessages:OpenControlChannel"]
247+
Resource = "*"
248+
},
249+
{
250+
Effect = "Allow"
251+
Action = ["sqs:SendMessage"]
252+
Resource = "*"
253+
}
254+
]
255+
})
256+
}
257+
'''
258+
)
259+
code, out = run_policy_validator(terraform_dir)
260+
assert code == 1, out
261+
assert "no_wildcard_iam" in out
262+
263+
141264
def test_heroku_policy_check_runs_after_eks_fragment() -> None:
142265
phase_dir = (
143266
PLUGIN_SKILL_ROOT.parent
@@ -833,7 +956,7 @@ def test_every_fixture_matches_its_good_bad_prefix() -> None:
833956
# Exact, not `>=`: a floor cannot detect fixtures being deleted down to it,
834957
# which is the removal this assertion exists to catch. Update deliberately
835958
# when adding or removing a fixture.
836-
assert len(fixture_dirs) == 31, f"fixture count changed: {[d.name for d in fixture_dirs]}"
959+
assert len(fixture_dirs) == 32, f"fixture count changed: {[d.name for d in fixture_dirs]}"
837960
for fixture in fixture_dirs:
838961
code, out = run_policy_validator(fixture)
839962
if fixture.name.startswith("bad-"):

0 commit comments

Comments
 (0)