tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.List files in /app
ls -la /app/
total 28 drwxr-xr-x 1 root root 4096 Apr 22 19:10 . drwxr-xr-x 1 root root 4096 Apr 23 05:40 .. -rw-r--r-- 1 root root 4137 Apr 22 19:10 handler.py -rwxr-xr-x 1 root root 6097 Apr 22 19:10 setup.sh drwxr-xr-x 2 root root 4096 Apr 22 19:10 starter [stdout] total 28 drwxr-xr-x 1 root root 4096 Apr 22 19:10 . drwxr-xr-x 1 root root 4096 Apr 23 05:40 .. -rw-r--r-- 1 root root 4137 Apr 22 19:10 handler.py -rwxr-xr-x 1 root root 6097 Apr 22 19:10 setup.sh drwxr-xr-x 2 root root 4096 Apr 22 19:10 starter
/app/handler.py
1 # Rotation Lambda for AWS Secrets Manager, "single-user" pattern
2 # (no external database, value is self-contained).
3 #
4 # Secrets Manager calls this function 4 times in sequence per rotation,
5 # passing a Step field: createSecret, setSecret, testSecret, finishSecret.
6 # Each step must return cleanly for the rotation to advance.
7 #
8 # Reference:
9 # https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotate-secrets_lambda-functions.html
10
11 import json
12 import logging
13 import os
14
15 import boto3
16
17 logger = logging.getLogger()
18 logger.setLevel(logging.INFO)
19
20 ENDPOINT = os.environ.get("SECRETS_MANAGER_ENDPOINT") or os.environ.get(
21 "AWS_ENDPOINT_URL"
22 )
23
24
25 def _client():
26 return boto3.client("secretsmanager", endpoint_url=ENDPOINT) if ENDPOINT else boto3.client("secretsmanager")
27
28
29 def lambda_handler(event, context):
30 arn = event["SecretId"]
31 token = event["ClientRequestToken"]
32 step = event["Step"]
33
34 client = _client()
35
36 desc = client.describe_secret(SecretId=arn)
37 if not desc.get("RotationEnabled"):
38 logger.error("Secret %s is not enabled for rotation", arn)
39 raise ValueError(f"Secret {arn} is not enabled for rotation")
40
41 versions = desc.get("VersionIdsToStages", {})
42 if token not in versions:
43 logger.error("Secret version %s has no stage for rotation of %s", token, arn)
44 raise ValueError(f"Secret version {token} has no stage for rotation of secret {arn}")
45 if "AWSCURRENT" in versions[token]:
46 logger.info("Secret version %s already AWSCURRENT for %s", token, arn)
47 return
48 if "AWSPENDING" not in versions[token]:
49 logger.error("Secret version %s not staged as AWSPENDING for %s", token, arn)
50 raise ValueError(f"Secret version {token} not set as AWSPENDING for rotation of secret {arn}")
51
52 if step == "createSecret":
53 create_secret(client, arn, token)
54 elif step == "setSecret":
55 set_secret(client, arn, token)
56 elif step == "testSecret":
57 test_secret(client, arn, token)
58 elif step == "finishSecret":
59 finish_secret(client, arn, token)
60 else:
61 raise ValueError(f"Invalid step parameter: {step}")
62
63
64 def create_secret(client, arn, token):
65 # Generate a new candidate value and stash it as AWSPENDING.
66 client.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT")
67
68 try:
69 client.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING")
70 logger.info("createSecret: pending version %s already exists", token)
71 except client.exceptions.ResourceNotFoundException:
72 password = client.get_random_password(PasswordLength=32, ExcludePunctuation=True)
73 client.put_secret_value(
74 SecretId=arn,
75 ClientRequestToken=token,
76 SecretString=json.dumps({"password": password["RandomPassword"]}),
77 VersionStages=["AWSPENDING"],
78 )
79 logger.info("createSecret: stored new AWSPENDING version %s", token)
80
81
82 def set_secret(client, arn, token):
83 # No external system to update for a self-contained secret; validate
84 # that both AWSCURRENT and AWSPENDING are readable and move on.
85 client.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT")
86 client.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING")
87 logger.info("setSecret: both stages readable")
88
89
90 def test_secret(client, arn, token):
91 # Sanity-check the pending value parses as JSON with a non-empty
92 # password field. Real handlers would attempt a login here.
93 pending = client.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING")
94 parsed = json.loads(pending["SecretString"])
95 if not parsed.get("password"):
96 raise ValueError("testSecret: pending value has no password")
97 logger.info("testSecret: pending value validated")
98
99
100 def finish_secret(client, arn, token):
101 # TODO: promote the AWSPENDING version to AWSCURRENT here.
102 # Without this, rotation will appear to succeed from Secrets
103 # Manager's orchestrator but AWSCURRENT never advances and
104 # consumers keep reading the old password.
105 logger.info("finishSecret: (no-op)")
106
/app/setup.sh
1 #!/bin/bash
2 # Seeds LocalStack with the broken-by-design state the agent inherits:
3 # - customer-managed KMS CMK (alias alias/app-rotation-key)
4 # - Secrets Manager secret encrypted with that CMK
5 # - IAM role for the rotation Lambda (with deliberately incomplete policy)
6 # - Rotation Lambda (with a subtle bug in finishSecret)
7 # - Rotation configured on the secret, pointing at the Lambda
8 #
9 # The task expects these resources to already exist when the agent starts
10 # working. The agent must find the bugs and make `rotate-secret` actually
11 # advance AWSCURRENT end-to-end.
12
13 set -euo pipefail
14
15 REGION="${AWS_DEFAULT_REGION:-us-east-1}"
16 ACCOUNT_ID="000000000000"
17 SECRET_NAME="app/db/password"
18 ROLE_NAME="secrets-rotator-role"
19 FUNCTION="secrets-rotator"
20 KEY_ALIAS="alias/app-rotation-key"
21
22 log() { echo "[setup] $*" >&2; }
23
24 log "waiting for localstack health..."
25 for _ in $(seq 1 60); do
26 if curl -sf http://localstack:4566/_localstack/health | grep -q '"secretsmanager": "available"'; then
27 break
28 fi
29 sleep 2
30 done
31
32 # 1. CMK with a minimal key policy (root admin only , no grant for the
33 # rotation Lambda role yet).
34 log "creating KMS CMK"
35 KEY_POLICY=$(cat <<JSON
36 {
37 "Version": "2012-10-17",
38 "Id": "app-rotation-key-policy",
39 "Statement": [
40 {
41 "Sid": "EnableRootAdmin",
42 "Effect": "Allow",
43 "Principal": { "AWS": "arn:aws:iam::${ACCOUNT_ID}:root" },
44 "Action": "kms:*",
45 "Resource": "*"
46 }
47 ]
48 }
49 JSON
50 )
51 KEY_ID=$(aws kms create-key \
52 --description "Customer CMK for app/db/password" \
53 --key-usage ENCRYPT_DECRYPT \
54 --policy "$KEY_POLICY" \
55 --query 'KeyMetadata.KeyId' --output text)
56 aws kms create-alias --alias-name "$KEY_ALIAS" --target-key-id "$KEY_ID" >/dev/null
57 KEY_ARN="arn:aws:kms:${REGION}:${ACCOUNT_ID}:key/${KEY_ID}"
58 log "created CMK $KEY_ID"
59
60 # 2. Secret, encrypted with the CMK.
61 log "creating secret"
62 aws secretsmanager create-secret \
63 --name "$SECRET_NAME" \
64 --kms-key-id "$KEY_ARN" \
65 --secret-string '{"password": "initial-placeholder-value"}' \
66 --description "App DB password, rotated by Lambda" >/dev/null
67
68 SECRET_ARN=$(aws secretsmanager describe-secret --secret-id "$SECRET_NAME" \
69 --query 'ARN' --output text)
70 log "created secret $SECRET_ARN"
71
72 # 3. IAM role for the rotation Lambda. Deliberately incomplete , has
73 # secretsmanager:* but no KMS actions. The broken state the agent
74 # inherits.
75 log "creating rotation Lambda role"
76 TRUST=$(cat <<'JSON'
77 {
78 "Version": "2012-10-17",
79 "Statement": [
80 {"Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}
81 ]
82 }
83 JSON
84 )
85 aws iam create-role \
86 --role-name "$ROLE_NAME" \
87 --assume-role-policy-document "$TRUST" >/dev/null
88
89 aws iam attach-role-policy \
90 --role-name "$ROLE_NAME" \
91 --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
92
93 INLINE=$(cat <<JSON
94 {
95 "Version": "2012-10-17",
96 "Statement": [
97 {
98 "Effect": "Allow",
99 "Action": [
100 "secretsmanager:DescribeSecret",
101 "secretsmanager:GetSecretValue",
102 "secretsmanager:PutSecretValue",
103 "secretsmanager:UpdateSecretVersionStage"
104 ],
105 "Resource": "${SECRET_ARN}"
106 },
107 {
108 "Effect": "Allow",
109 "Action": "secretsmanager:GetRandomPassword",
110 "Resource": "*"
111 }
112 ]
113 }
114 JSON
115 )
116 aws iam put-role-policy \
117 --role-name "$ROLE_NAME" \
118 --policy-name SecretsRotatorPolicy \
119 --policy-document "$INLINE"
120
121 ROLE_ARN=$(aws iam get-role --role-name "$ROLE_NAME" --query 'Role.Arn' --output text)
122 log "created role $ROLE_ARN"
123
124 # 4. Package + deploy the rotation Lambda (with its buggy handler).
125 log "packaging rotation Lambda"
126 WORKDIR="$(mktemp -d)"
127 cp /app/handler.py "${WORKDIR}/handler.py"
128 (cd "$WORKDIR" && zip -q handler.zip handler.py)
129
130 aws lambda create-function \
131 --function-name "$FUNCTION" \
132 --runtime python3.11 \
133 --role "$ROLE_ARN" \
134 --handler handler.lambda_handler \
135 --timeout 30 \
136 --memory-size 256 \
137 --environment "Variables={SECRETS_MANAGER_ENDPOINT=http://localstack:4566}" \
138 --zip-file "fileb://${WORKDIR}/handler.zip" >/dev/null
139
140 for _ in $(seq 1 30); do
141 STATE=$(aws lambda get-function --function-name "$FUNCTION" \
142 --query 'Configuration.State' --output text 2>/dev/null || echo "Pending")
143 [ "$STATE" = "Active" ] && break
144 sleep 1
145 done
146 log "Lambda $FUNCTION active"
147
148 # NOTE: deliberately NOT calling `aws lambda add-permission` with
149 # principal secretsmanager.amazonaws.com. The agent must add that.
150
151 # 5. Attach rotation config on the secret. Secrets Manager refuses to
152 # attach unless the rotation Lambda already grants it InvokeFunction,
153 # so we temporarily add that permission, attach rotation, then remove
154 # the permission so the agent still has to re-add it as part of their
155 # fix. Net result: RotationEnabled=true but rotation fails at runtime
156 # because of the other broken layers (KMS grants, handler bug, Lambda
157 # resource policy).
158 log "attaching rotation config (temp Lambda permission)"
159 FUNCTION_ARN="arn:aws:lambda:${REGION}:${ACCOUNT_ID}:function:${FUNCTION}"
160 aws lambda add-permission \
161 --function-name "$FUNCTION" \
162 --statement-id TempRotationSetup \
163 --action lambda:InvokeFunction \
164 --principal secretsmanager.amazonaws.com \
165 --source-arn "$SECRET_ARN" >/dev/null 2>&1 || true
166
167 if aws secretsmanager rotate-secret \
168 --secret-id "$SECRET_NAME" \
169 --rotation-lambda-arn "$FUNCTION_ARN" \
170 --rotation-rules AutomaticallyAfterDays=30 \
171 --no-rotate-immediately >/dev/null 2>&1; then
172 log "rotation attached (no immediate trigger)"
173 elif aws secretsmanager rotate-secret \
174 --secret-id "$SECRET_NAME" \
175 --rotation-lambda-arn "$FUNCTION_ARN" \
176 --rotation-rules AutomaticallyAfterDays=30 >/dev/null 2>&1; then
177 log "rotation attached (first run may have fired)"
178 else
179 log "rotate-secret failed even with temp permission , check LocalStack compat"
180 fi
181
182 # Strip the temp permission so the agent still has to add it.
183 aws lambda remove-permission \
184 --function-name "$FUNCTION" \
185 --statement-id TempRotationSetup >/dev/null 2>&1 || true
186
187 log "done"
188
Describe the secret
aws secretsmanager describe-secret --secret-id app/db/password
{
"ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi",
"Name": "app/db/password",
"Description": "App DB password, rotated by Lambda",
"KmsKeyId": "arn:aws:kms:us-east-1:000000000000:key/b44bcb92-dc70-40e7-b176-b059f31bf8d1",
"RotationEnabled": true,
"RotationLambdaARN": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator",
"RotationRules": {
"AutomaticallyAfterDays": 30
},
"LastRotatedDate": "2026-04-23T05:40:33+00:00",
"LastChangedDate": "2026-04-23T05:40:29.092000+00:00",
"LastAccessedDate": "2026-04-23T00:00:00+00:00",
"NextRotationDate": "2026-05-23T05:40:27+00:00",
"VersionIdsToStages": {
"8f467236-f87b-4cc8-aed0-f75d20522b0a": [
"AWSCURRENT"
],
"dd734fd9-3aa7-485a-af0e-3162b55ef9c2": [
"AWSPENDING"
]
},
"CreatedDate": "2026-04-23T05:40:17.637642+00:00"
}
[stdout]
{
"ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi",
"Name": "app/db/password",
"Description": "App DB password, rotated by Lambda",
"KmsKeyId": "arn:aws:kms:us-east-1:000000000000:key/b44bcb92-dc70-40e7-b176-b059f31bf8d1",
"RotationEnabled": true,
"RotationLambdaARN": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator",
"RotationRules": {
"AutomaticallyAfterDays": 30
},
"LastRotatedDate": "2026-04-23T05:40:33+00:00",
"LastChangedDate": "2026-04-23T05:40:29.092000+00:00",
"LastAccessedDate": "2026-04-23T00:00:00+00:00",
"NextRotationDate": "2026-05-23T05:40:27+00:00",
"VersionIdsToStages": {
"8f467236-f87b-4cc8-aed0-f75d20522b0a": [
"AWSCURRENT"
],
"dd734fd9-3aa7-485a-af0e-3162b55ef9c2": [
"AWSPENDING"
]
},
"CreatedDate": "2026-04-23T05:40:17.637642+00:00"
}Get current KMS key policy
aws kms get-key-policy --key-id b44bcb92-dc70-40e7-b176-b059f31bf8d1 --policy-name default --output text
{
"Version": "2012-10-17",
"Id": "app-rotation-key-policy",
"Statement": [
{
"Sid": "EnableRootAdmin",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:root" },
"Action": "kms:*",
"Resource": "*"
}
]
}
[stdout]
{
"Version": "2012-10-17",
"Id": "app-rotation-key-policy",
"Statement": [
{
"Sid": "EnableRootAdmin",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:root" },
"Action": "kms:*",
"Resource": "*"
}
]
}Get inline role policy
aws iam get-role-policy --role-name secrets-rotator-role --policy-name SecretsRotatorPolicy
{
"RoleName": "secrets-rotator-role",
"PolicyName": "SecretsRotatorPolicy",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi"
},
{
"Effect": "Allow",
"Action": "secretsmanager:GetRandomPassword",
"Resource": "*"
}
]
}
}
[stdout]
{
"RoleName": "secrets-rotator-role",
"PolicyName": "SecretsRotatorPolicy",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi"
},
{
"Effect": "Allow",
"Action": "secretsmanager:GetRandomPassword",
"Resource": "*"
}
]
}
}Get lambda resource policy
aws lambda get-policy --function-name secrets-rotator 2>&1 || true
aws: [ERROR]: An error occurred (ResourceNotFoundException) when calling the GetPolicy operation: The resource you requested does not exist. Additional error details: Type: User [stdout] aws: [ERROR]: An error occurred (ResourceNotFoundException) when calling the GetPolicy operation: The resource you requested does not exist. Additional error details: Type: User
Attempt rotate-secret to see current failure
aws secretsmanager rotate-secret --secret-id app/db/password 2>&1 || echo "---failed---"
{
"ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi",
"Name": "app/db/password",
"VersionId": "dd734fd9-3aa7-485a-af0e-3162b55ef9c2"
}
[stdout]
{
"ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi",
"Name": "app/db/password",
"VersionId": "dd734fd9-3aa7-485a-af0e-3162b55ef9c2"
}Check current secret status
sleep 2 && aws secretsmanager describe-secret --secret-id app/db/password | jq '.VersionIdsToStages' echo "---" aws secretsmanager get-secret-value --secret-id app/db/password | jq '.SecretString, .VersionId'
{
"8f467236-f87b-4cc8-aed0-f75d20522b0a": [
"AWSCURRENT"
],
"dd734fd9-3aa7-485a-af0e-3162b55ef9c2": [
"AWSPENDING"
]
}
---
"{\"password\": \"initial-placeholder-value\"}"
"8f467236-f87b-4cc8-aed0-f75d20522b0a"
[stdout]
{
"8f467236-f87b-4cc8-aed0-f75d20522b0a": [
"AWSCURRENT"
],
"dd734fd9-3aa7-485a-af0e-3162b55ef9c2": [
"AWSPENDING"
]
}
---
"{\"password\": \"initial-placeholder-value\"}"
"8f467236-f87b-4cc8-aed0-f75d20522b0a"/app/handler.py
def finish_secret(client, arn, token):
# TODO: promote the AWSPENDING version to AWSCURRENT here.
# Without this, rotation will appear to succeed from Secrets
# Manager's orchestrator but AWSCURRENT never advances and
# consumers keep reading the old password.
logger.info("finishSecret: (no-op)")def finish_secret(client, arn, token):
desc = client.describe_secret(SecretId=arn)
current_version = None
for version_id, stages in desc.get("VersionIdsToStages", {}).items():
if "AWSCURRENT" in stages:
if version_id == token:
logger.info("finishSecret: version %s already AWSCURRENT", token)
return
current_version = version_id
break
client.update_secret_version_stage(
SecretId=arn,
VersionStage="AWSCURRENT",
MoveToVersionId=token,
RemoveFromVersionId=current_version,
)
logger.info("finishSecret: promoted %s to AWSCURRENT", token)Repackage and update Lambda code
cd /tmp && cp /app/handler.py handler.py && zip -q handler.zip handler.py && aws lambda update-function-code --function-name secrets-rotator --zip-file fileb:///tmp/handler.zip --output json | jq '.LastUpdateStatus, .State'
"InProgress" "Active" Shell cwd was reset to /app [stdout] "InProgress" "Active" [stderr] Shell cwd was reset to /app
Wait for Lambda update to complete
for _ in $(seq 1 30); do S=$(aws lambda get-function --function-name secrets-rotator --query 'Configuration.LastUpdateStatus' --output text) [ "$S" = "Successful" ] && break sleep 1 done echo "LastUpdateStatus: $S"
LastUpdateStatus: Successful [stdout] LastUpdateStatus: Successful
Add Secrets Manager invoke permission to Lambda
SECRET_ARN=$(aws secretsmanager describe-secret --secret-id app/db/password --query ARN --output text) aws lambda add-permission \ --function-name secrets-rotator \ --statement-id SecretsManagerInvoke \ --action lambda:InvokeFunction \ --principal secretsmanager.amazonaws.com \ --source-arn "$SECRET_ARN"
{
"Statement": "{\"Sid\": \"SecretsManagerInvoke\", \"Effect\": \"Allow\", \"Action\": \"lambda:InvokeFunction\", \"Resource\": \"arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator\", \"Principal\": {\"Service\": \"secretsmanager.amazonaws.com\"}, \"Condition\": {\"ArnLike\": {\"AWS:SourceArn\": \"arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi\"}}}"
}
[stdout]
{
"Statement": "{\"Sid\": \"SecretsManagerInvoke\", \"Effect\": \"Allow\", \"Action\": \"lambda:InvokeFunction\", \"Resource\": \"arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator\", \"Principal\": {\"Service\": \"secretsmanager.amazonaws.com\"}, \"Condition\": {\"ArnLike\": {\"AWS:SourceArn\": \"arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi\"}}}"
}Get CMK identifiers
KEY_ID=$(aws kms describe-key --key-id alias/app-rotation-key --query 'KeyMetadata.KeyId' --output text) KEY_ARN=$(aws kms describe-key --key-id alias/app-rotation-key --query 'KeyMetadata.Arn' --output text) echo "KEY_ID=$KEY_ID" echo "KEY_ARN=$KEY_ARN"
KEY_ID=b44bcb92-dc70-40e7-b176-b059f31bf8d1 KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b44bcb92-dc70-40e7-b176-b059f31bf8d1 [stdout] KEY_ID=b44bcb92-dc70-40e7-b176-b059f31bf8d1 KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b44bcb92-dc70-40e7-b176-b059f31bf8d1
Update KMS key policy
cat > /tmp/key-policy.json <<'JSON'
{
"Version": "2012-10-17",
"Id": "app-rotation-key-policy",
"Statement": [
{
"Sid": "EnableRootAdmin",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:root" },
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowSecretsManagerService",
"Effect": "Allow",
"Principal": { "Service": "secretsmanager.amazonaws.com" },
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "*"
},
{
"Sid": "AllowRotationLambdaRoleViaSecretsManager",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:role/secrets-rotator-role" },
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"kms:ViaService": "secretsmanager.us-east-1.amazonaws.com"
}
}
}
]
}
JSON
aws kms put-key-policy \
--key-id b44bcb92-dc70-40e7-b176-b059f31bf8d1 \
--policy-name default \
--policy file:///tmp/key-policy.json
echo "key policy updated"key policy updated [stdout] key policy updated
Update rotation role inline policy
cat > /tmp/role-policy.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi"
},
{
"Effect": "Allow",
"Action": "secretsmanager:GetRandomPassword",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/b44bcb92-dc70-40e7-b176-b059f31bf8d1",
"Condition": {
"StringEquals": {
"kms:ViaService": "secretsmanager.us-east-1.amazonaws.com"
}
}
}
]
}
JSON
aws iam put-role-policy \
--role-name secrets-rotator-role \
--policy-name SecretsRotatorPolicy \
--policy-document file:///tmp/role-policy.json
echo "role policy updated"role policy updated [stdout] role policy updated
Capture before state and trigger rotation
BEFORE=$(aws secretsmanager get-secret-value --secret-id app/db/password) BEFORE_VID=$(echo "$BEFORE" | jq -r '.VersionId') BEFORE_VAL=$(echo "$BEFORE" | jq -r '.SecretString') echo "BEFORE VersionId: $BEFORE_VID" echo "BEFORE Value: $BEFORE_VAL" echo "---" aws secretsmanager rotate-secret --secret-id app/db/password echo "---"
BEFORE VersionId: 8f467236-f87b-4cc8-aed0-f75d20522b0a
BEFORE Value: {"password": "initial-placeholder-value"}
---
{
"ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi",
"Name": "app/db/password",
"VersionId": "dd734fd9-3aa7-485a-af0e-3162b55ef9c2"
}
---
[stdout]
BEFORE VersionId: 8f467236-f87b-4cc8-aed0-f75d20522b0a
BEFORE Value: {"password": "initial-placeholder-value"}
---
{
"ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-PLcOFi",
"Name": "app/db/password",
"VersionId": "dd734fd9-3aa7-485a-af0e-3162b55ef9c2"
}
---Check rotation result
sleep 5
aws secretsmanager describe-secret --secret-id app/db/password | jq '.VersionIdsToStages'
echo "---"
AFTER=$(aws secretsmanager get-secret-value --secret-id app/db/password)
echo "$AFTER" | jq '{VersionId: .VersionId, SecretString: .SecretString}'{
"8f467236-f87b-4cc8-aed0-f75d20522b0a": [
"AWSPREVIOUS"
],
"dd734fd9-3aa7-485a-af0e-3162b55ef9c2": [
"AWSPENDING",
"AWSCURRENT"
]
}
---
{
"VersionId": "dd734fd9-3aa7-485a-af0e-3162b55ef9c2",
"SecretString": "{\"password\": \"C69fy7GXtajTYxGalejTzSl232T9ObC1\"}"
}
[stdout]
{
"8f467236-f87b-4cc8-aed0-f75d20522b0a": [
"AWSPREVIOUS"
],
"dd734fd9-3aa7-485a-af0e-3162b55ef9c2": [
"AWSPENDING",
"AWSCURRENT"
]
}
---
{
"VersionId": "dd734fd9-3aa7-485a-af0e-3162b55ef9c2",
"SecretString": "{\"password\": \"C69fy7GXtajTYxGalejTzSl232T9ObC1\"}"
}Second rotation round-trip test
BEFORE_VID=$(aws secretsmanager get-secret-value --secret-id app/db/password --query VersionId --output text) BEFORE_VAL=$(aws secretsmanager get-secret-value --secret-id app/db/password --query SecretString --output text) aws secretsmanager rotate-secret --secret-id app/db/password >/dev/null sleep 6 AFTER_VID=$(aws secretsmanager get-secret-value --secret-id app/db/password --query VersionId --output text) AFTER_VAL=$(aws secretsmanager get-secret-value --secret-id app/db/password --query SecretString --output text) echo "BEFORE: $BEFORE_VID | $BEFORE_VAL" echo "AFTER : $AFTER_VID | $AFTER_VAL" echo "---" aws secretsmanager describe-secret --secret-id app/db/password | jq '.VersionIdsToStages'
BEFORE: dd734fd9-3aa7-485a-af0e-3162b55ef9c2 | {"password": "C69fy7GXtajTYxGalejTzSl232T9ObC1"}
AFTER : c81623f4-9424-4e17-adb0-c44cb842066f | {"password": "Y4FhcI8nWSErJ3HMfU9S81VYhmyG7bQ5"}
---
{
"dd734fd9-3aa7-485a-af0e-3162b55ef9c2": [
"AWSPREVIOUS"
],
"c81623f4-9424-4e17-adb0-c44cb842066f": [
"AWSPENDING",
"AWSCURRENT"
]
}
[stdout]
BEFORE: dd734fd9-3aa7-485a-af0e-3162b55ef9c2 | {"password": "C69fy7GXtajTYxGalejTzSl232T9ObC1"}
AFTER : c81623f4-9424-4e17-adb0-c44cb842066f | {"password": "Y4FhcI8nWSErJ3HMfU9S81VYhmyG7bQ5"}
---
{
"dd734fd9-3aa7-485a-af0e-3162b55ef9c2": [
"AWSPREVIOUS"
],
"c81623f4-9424-4e17-adb0-c44cb842066f": [
"AWSPENDING",
"AWSCURRENT"
]
}No file changes, the agent left the workspace untouched.
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/ssw4QxpqYKJq5yr2Os018/bin/python
cachedir: .pytest_cache
rootdir: /tests
plugins: json-ctrf-0.3.5
collecting ... collected 8 items
../tests/test_outputs.py::test_aws_cli_binary_is_real PASSED [ 12%]
../tests/test_outputs.py::test_localstack_reachable PASSED [ 25%]
../tests/test_outputs.py::test_secret_exists_and_uses_customer_cmk PASSED [ 37%]
../tests/test_outputs.py::test_rotation_lambda_exists PASSED [ 50%]
../tests/test_outputs.py::test_role_policy_grants_kms_actions PASSED [ 62%]
../tests/test_outputs.py::test_kms_key_policy_grants_lambda_role PASSED [ 75%]
../tests/test_outputs.py::test_lambda_permission_allows_secretsmanager_invoke PASSED [ 87%]
../tests/test_outputs.py::test_rotate_secret_advances_awscurrent PASSED [100%]
=============================== warnings summary ===============================
test_outputs.py: 14 warnings
/root/.cache/uv/archive-v0/ssw4QxpqYKJq5yr2Os018/lib/python3.12/site-packages/botocore/auth.py:424: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
datetime_now = datetime.datetime.utcnow()
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED ../tests/test_outputs.py::test_aws_cli_binary_is_real
PASSED ../tests/test_outputs.py::test_localstack_reachable
PASSED ../tests/test_outputs.py::test_secret_exists_and_uses_customer_cmk
PASSED ../tests/test_outputs.py::test_rotation_lambda_exists
PASSED ../tests/test_outputs.py::test_role_policy_grants_kms_actions
PASSED ../tests/test_outputs.py::test_kms_key_policy_grants_lambda_role
PASSED ../tests/test_outputs.py::test_lambda_permission_allows_secretsmanager_invoke
PASSED ../tests/test_outputs.py::test_rotate_secret_advances_awscurrent
======================== 8 passed, 14 warnings in 6.50s ========================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_af341506f3a342cb. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_af341506f3a342cb · verifier authoritative; classifier explanatory.