From 7e0d7b031a0b8dca55e4276348fc964da5f6f962 Mon Sep 17 00:00:00 2001 From: Roy Osherove <575051+royosherove@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:27:48 +0000 Subject: [PATCH] fix(idle-shutdown): give DescribeInstanceStatus Resource * in the wake policy EC2 Describe* actions do not support resource-level permissions. They match only "Resource": "*". The wake Lambda policy gave ec2:DescribeInstanceStatus only on the instance ARN, so the call in wake-lambda/index.mjs got AccessDenied before StartInstances ran. Move ec2:DescribeInstanceStatus to its own statement with Resource "*". Keep ec2:StartInstances limited to the instance ARN. Add tests/test-idle-wake-policy.sh, which reads the policy from install-idle.sh and checks both statements. Fixes #129 Crew: Drew (Kiro Crew Issue Radar) --- .../optional/idle-shutdown/install-idle.sh | 3 +- tests/test-idle-wake-policy.sh | 96 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100755 tests/test-idle-wake-policy.sh diff --git a/bootstraps/optional/idle-shutdown/install-idle.sh b/bootstraps/optional/idle-shutdown/install-idle.sh index bd99f9b..8d87d2e 100755 --- a/bootstraps/optional/idle-shutdown/install-idle.sh +++ b/bootstraps/optional/idle-shutdown/install-idle.sh @@ -233,8 +233,9 @@ JSON WAKE_INLINE=$(cat <&2 + exit 1 +fi + +RED='\033[0;31m'; GREEN='\033[0;32m'; BOLD='\033[1m'; NC='\033[0m' +PASS=0; FAIL=0 + +pass() { printf "${GREEN} \xE2\x9C\x93${NC} %s\n" "$1"; PASS=$((PASS + 1)); } +fail() { printf "${RED} \xE2\x9C\x97${NC} %s\n" "$1"; FAIL=$((FAIL + 1)); } + +printf "${BOLD}idle-shutdown wake Lambda IAM policy${NC}\n" + +# Get the heredoc body between 'WAKE_INLINE=$(cat <" or "FAIL ". +RESULTS="$(POLICY="$POLICY" python3 - <<'PY' +import json, os + +region, acct, inst = "us-east-1", "123456789012", "i-0123456789abcdef0" +text = (os.environ["POLICY"] + .replace("${REGION}", region) + .replace("${ACCT_ID}", acct) + .replace("${INSTANCE_ID}", inst)) +instance_arn = f"arn:aws:ec2:{region}:{acct}:instance/{inst}" + +def out(ok, msg): + print(("PASS " if ok else "FAIL ") + msg) + +try: + doc = json.loads(text) +except ValueError as e: + out(False, f"policy is valid JSON ({e})") + raise SystemExit(0) +out(True, "policy is valid JSON") + +def as_list(v): + return v if isinstance(v, list) else [v] + +stmts = as_list(doc.get("Statement", [])) +describe_seen = False +start_seen = False +for s in stmts: + actions = as_list(s.get("Action", [])) + resources = as_list(s.get("Resource", [])) + describes = [a for a in actions if a.startswith("ec2:Describe")] + if describes: + describe_seen = True + out(resources == ["*"], + f"{', '.join(describes)} uses Resource \"*\" (got {resources})") + if "ec2:StartInstances" in actions: + start_seen = True + out(resources == [instance_arn], + f"ec2:StartInstances is limited to the instance ARN (got {resources})") + +out(describe_seen, "policy allows ec2:DescribeInstanceStatus") +out(start_seen, "policy allows ec2:StartInstances") +PY +)" + +while IFS= read -r line; do + [[ -z "$line" ]] && continue + case "$line" in + PASS\ *) pass "${line#PASS }" ;; + *) fail "${line#FAIL }" ;; + esac +done <<< "$RESULTS" + +printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]]