Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ Before handing off to Design:
- [ ] If peering detected and VPC ID needed → `network.existing_vpc_id` is populated
- [ ] If Fir apps detected → `global.fir_intent` is populated (not null)
- [ ] `operational.containerization_status` is populated
- [ ] `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
- [ ] `design_constraints.compute_target.default` is one of: `"elastic_beanstalk"`, `"ecs-fargate"`, `"eks-managed"`, `"eks-or-ecs"`
- [ ] `design_constraints.compute_target.chosen_by` is one of: `"user"`, `"system_recommended"`, `"default"`
- [ ] `design_constraints.compute_target.overrides[]` entries, when present, have `formation`, `value`, `reason`, and `chosen_by`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,12 @@ Validate: must be valid ISO 8601 date, must be in the future.

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

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

---

#### Q14 — Alerting Preference
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,25 @@ resource "aws_iam_role" "ecs_task" {
Name = "${var.project_name}-${var.environment}-ecs-task"
}
}

# ECS Exec (the `heroku run bash` equivalent) opens an SSM channel from the task.
resource "aws_iam_role_policy" "ecs_task_exec" {
name = "${var.project_name}-${var.environment}-ecs-exec"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Resource = "*"
Comment thread
herosjourney marked this conversation as resolved.
Resolved
Comment thread
herosjourney marked this conversation as resolved.
Resolved
}]
})
}
```

---
Expand Down Expand Up @@ -1157,6 +1176,10 @@ resource "aws_ecs_service" "<app_sanitized>_<process_type>" {
name = "${var.project_name}-${var.environment}-<process_type>"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.<app_sanitized>_<process_type>.arn
# ECS Exec is the `heroku run bash` equivalent (MIGRATION_GUIDE.md § Day-to-day commands).
# It needs the ssmmessages permissions that aws_iam_role_policy.ecs_task_exec grants
# the task role in security.tf (Step 5).
enable_execute_command = true
# 0 while var.writers_quiesced is true, so a later apply (DNS rollback, a repair)
# cannot restart tasks that Phase 6 scaled to zero. Set the variable back to false
# only at the next intentional handoff.
Expand Down Expand Up @@ -1422,6 +1445,27 @@ resource "aws_elastic_beanstalk_environment" "<app_name>_<process_type>" {
value = var.eb_deployment_policy
}

# Stream instance logs to CloudWatch Logs so `aws logs tail` works (the
# `heroku logs --tail` equivalent in MIGRATION_GUIDE.md § Day-to-day commands).
# The Docker application log lands in
# /aws/elasticbeanstalk/<environment>/var/log/eb-docker/containers/eb-current-app/stdouterr.log
setting {
namespace = "aws:elasticbeanstalk:cloudwatch:logs"
name = "StreamLogs"
value = "true"
}

# RetentionInDays accepts only 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400,
# 545, 731, 1827, 3653. Clarify Q13 allows any integer 1–3653 (including a reused
# preferences.json), so the generator emits the smallest accepted value that is
# >= operational.log_retention_days (2 → 3, 45 → 60, 30 → 30; default 30). Never copy
# the raw preference: an unlisted value fails environment creation/update.
setting {
namespace = "aws:elasticbeanstalk:cloudwatch:logs"
name = "RetentionInDays"
value = "<smallest EB-accepted RetentionInDays >= preferences.json.operational.log_retention_days || 30>"
}

# {{IF process_type == "web"}}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
Expand Down Expand Up @@ -1467,6 +1511,34 @@ resource "aws_iam_role_policy_attachment" "eb_web_tier_<app_name>" {
policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
}

# Session Manager access for `aws ssm start-session` (the `heroku run bash` equivalent in
# MIGRATION_GUIDE.md § Day-to-day commands). The environment has no EC2 key pair and no
# :22 ingress, so `eb ssh` cannot reach an instance; AWSElasticBeanstalkWebTier grants no
# ssm:*/ssmmessages:* either. The SSM agent is preinstalled on the AL2023 Docker platform.
# Do NOT attach the AmazonSSMManagedInstanceCore managed policy here: it also allows
# ssm:GetParameter(s) on Resource "*", which would widen the parameter-read boundary that
# eb_read_secrets_<app_name> keeps under parameter/<project>/*. This inline policy carries
# only the actions the agent needs to register and open a session channel.
resource "aws_iam_role_policy" "eb_ssm_<app_name>" {
name = "${var.project_name}-eb-session-manager"
role = aws_iam_role.eb_instance_<app_name>.id

policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ssm:UpdateInstanceInformation",
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Resource = "*"
}]
})
}

resource "aws_iam_role_policy" "eb_read_secrets_<app_name>" {
name = "${var.project_name}-eb-read-secrets"
role = aws_iam_role.eb_instance_<app_name>.id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,12 @@ for the caller, so a false positive would block a real migration).
`aws_iam_user_policy`):

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

> The checker is a zero-dependency static HCL reader (no `terraform init`, no provider
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Compliant fixture — the ECS Exec task-role grant heroku-to-aws emits in security.tf
# (the `heroku run bash` equivalent). MUST pass (POLICY_OK): AWS defines no resource-level
# permissions for the four ssmmessages channel actions, so Resource "*" is the only
# possible shape and no_wildcard_iam exempts a statement that lists ONLY those actions.
resource "aws_iam_role" "ecs_task" {
name = "${var.project_name}-${var.environment}-ecs-task"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}]
})
}

resource "aws_iam_role_policy" "ecs_task_exec" {
name = "${var.project_name}-${var.environment}-ecs-exec"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Resource = "*"
}]
})
}

# A scoped statement next to it must not be disturbed by the exemption.
resource "aws_iam_role_policy" "ecs_task_queue" {
name = "${var.project_name}-${var.environment}-ecs-queue"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["sqs:SendMessage", "sqs:ReceiveMessage"]
Resource = "arn:aws:sqs:us-east-1:123456789012:${var.project_name}-jobs"
}]
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,18 @@ either string form (`"*"`) or single-element list form (`["*"]`). Scope to speci
and resource ARNs. A list that also contains scoped entries (e.g. `["s3:GetObject", ...]`) is
not a blanket wildcard and is allowed.

The narrow exception is an isolated `elasticbeanstalk:CreateStorageLocation`
statement with `Resource = "*"`. AWS does not define resource-level permissions
for that action; combining it with any other action does not qualify for the
exception.
Two narrow exceptions exist, both for actions that AWS defines no resource-level
permissions for, so `Resource = "*"` is the only way to write them:

- an isolated `elasticbeanstalk:CreateStorageLocation` statement;
- an ECS Exec statement whose `Action` list contains only the four `ssmmessages` channel
actions (`CreateControlChannel`, `CreateDataChannel`, `OpenControlChannel`,
`OpenDataChannel`) — the task-role grant behind `aws ecs execute-command`.

The exception is statement-shaped and literal-only: combining either with any other action
(for example `ssmmessages:*`, `ssm:*`, or `s3:*` in the same list) does not qualify, and
neither does a list with a non-literal member (`local.extra_action`, `var.x`, a function
call) — the checker cannot see what it resolves to, so that statement is flagged.

**Gate mapping:** `no_wildcard_iam`. The gate scans literal policy JSON (heredoc or
`jsonencode({...})`) in the resources above. `aws_iam_policy_document` **data sources** fail
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,129 @@ def test_required_wildcard_action_cannot_hide_other_actions() -> None:
assert "no_wildcard_iam" in out


def _heroku_ecs_exec_policy_block() -> str:
"""The ECS Exec task-role statement exactly as generate-terraform.md emits it."""
fragment = (
PLUGIN_SKILL_ROOT.parent
/ "heroku-to-aws"
/ "references"
/ "phases"
/ "generate"
/ "generate-terraform.md"
).read_text()
start = fragment.index('resource "aws_iam_role_policy" "ecs_task_exec"')
end = fragment.index("\n}\n", start) + 3
return fragment[start:end]


def test_heroku_ecs_exec_emitted_statement_passes() -> None:
# AWS defines no resource-level permissions for ssmmessages:*; the emitted
# statement lists only the four channel actions, so it is a required grant.
block = _heroku_ecs_exec_policy_block()
for action in (
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
):
assert action in block
with tempfile.TemporaryDirectory() as tmp:
terraform_dir = Path(tmp)
(terraform_dir / "ecs-exec.tf").write_text(block)
code, out = run_policy_validator(terraform_dir)
assert code == 0, out
assert "POLICY_OK" in out


def test_ssmmessages_exception_cannot_hide_other_actions() -> None:
# The exception is statement-shaped: adding any non-channel action (here the
# service wildcard and an unrelated service) to the list must still fail.
for extra in ('"ssmmessages:*"', '"ssm:StartSession"', '"s3:*"'):
with tempfile.TemporaryDirectory() as tmp:
terraform_dir = Path(tmp)
(terraform_dir / "policy.tf").write_text(
f'''
resource "aws_iam_role_policy" "mixed" {{
role = "example-role"
policy = jsonencode({{
Statement = [{{
Effect = "Allow"
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
{extra}
]
Resource = "*"
}}]
}})
}}
'''
)
code, out = run_policy_validator(terraform_dir)
assert code == 1, (extra, out)
assert "no_wildcard_iam" in out, (extra, out)


def test_ssmmessages_exception_requires_every_member_to_be_a_literal() -> None:
# A non-literal list member (local/var/function) may resolve to anything —
# here `local.extra_action = "s3:*"` — so the statement must keep failing
# no_wildcard_iam even though every quoted literal in the list is allowlisted.
for extra in ("local.extra_action", "var.extra_action", 'format("%s", "s3:*")'):
with tempfile.TemporaryDirectory() as tmp:
terraform_dir = Path(tmp)
(terraform_dir / "policy.tf").write_text(
f'''
locals {{ extra_action = "s3:*" }}
resource "aws_iam_role_policy" "mixed_expression" {{
role = "example-role"
policy = jsonencode({{
Statement = [{{
Effect = "Allow"
Action = ["ssmmessages:CreateControlChannel", {extra}]
Resource = "*"
}}]
}})
}}
'''
)
code, out = run_policy_validator(terraform_dir)
assert code == 1, (extra, out)
assert "no_wildcard_iam" in out, (extra, out)


def test_unrelated_wildcard_list_still_fails_alongside_ecs_exec() -> None:
# A second, over-broad statement in the same policy is still flagged even
# when the approved ECS Exec statement sits next to it.
with tempfile.TemporaryDirectory() as tmp:
terraform_dir = Path(tmp)
(terraform_dir / "policy.tf").write_text(
'''
resource "aws_iam_role_policy" "two_statements" {
role = "example-role"
policy = jsonencode({
Statement = [
{
Effect = "Allow"
Action = ["ssmmessages:CreateControlChannel", "ssmmessages:OpenControlChannel"]
Resource = "*"
},
{
Effect = "Allow"
Action = ["sqs:SendMessage"]
Resource = "*"
}
]
})
}
'''
)
code, out = run_policy_validator(terraform_dir)
assert code == 1, out
assert "no_wildcard_iam" in out


def test_heroku_policy_check_runs_after_eks_fragment() -> None:
phase_dir = (
PLUGIN_SKILL_ROOT.parent
Expand Down Expand Up @@ -833,7 +956,7 @@ def test_every_fixture_matches_its_good_bad_prefix() -> None:
# Exact, not `>=`: a floor cannot detect fixtures being deleted down to it,
# which is the removal this assertion exists to catch. Update deliberately
# when adding or removing a fixture.
assert len(fixture_dirs) == 31, f"fixture count changed: {[d.name for d in fixture_dirs]}"
assert len(fixture_dirs) == 32, f"fixture count changed: {[d.name for d in fixture_dirs]}"
for fixture in fixture_dirs:
code, out = run_policy_validator(fixture)
if fixture.name.startswith("bad-"):
Expand Down
Loading
Loading