AWS Systems Manager: Hardening Runbook
AWS Systems Manager: Hardening Runbook
Fifteen phases take one account and Region from a default Systems Manager setup to a hardened one: DHMC and IMDSv2 for node identity, private endpoints, a KMS key and encrypted log destinations, an OS baseline for ssm-user, hardened session preferences, least-privilege operator access and just-in-time approval, safe documents, patching, Parameter Store, organization guardrails, detection and hybrid nodes, then a verification pass. Tick as you go. Concepts, the nine diagrams and the control matrix live in bubim-awsssm.
Prepare
0 / 0Run as an administrator of the workload account. JSON files below use account 111122223333 and Region eu-central-1; step 0.3 swaps in your values.
aws --version session-manager-plugin --version jq --version
export AWS_REGION=eu-central-1 export ACCOUNT=$(aws sts get-caller-identity --query Account --output text) export ORG_ID=$(aws organizations describe-organization --query Organization.Id --output text) export VPC=REPLACE_WITH_VPC_ID export PRIV_SUBNETS="REPLACE_WITH_SUBNET_A REPLACE_WITH_SUBNET_B" export PRIV_RT=REPLACE_WITH_PRIVATE_ROUTE_TABLE_ID export VPC_CIDR=$(aws ec2 describe-vpcs --vpc-ids $VPC --query 'Vpcs[0].CidrBlock' --output text) export LOG_BUCKET=ssm-session-logs-$ACCOUNT-$AWS_REGION echo $ACCOUNT $ORG_ID $VPC $VPC_CIDR
mkdir -p ~/ssm-hardening && cd ~/ssm-hardening sed -i "s/111122223333/$ACCOUNT/g; s/eu-central-1/$AWS_REGION/g; s/o-REPLACE_WITH_ORG_ID/$ORG_ID/g" ./*.json
aws ssm describe-instance-information \ --query 'InstanceInformationList[].[InstanceId,PingStatus,AgentVersion,PlatformName,ResourceType]' --output table aws ec2 describe-instances --filters Name=instance-state-name,Values=running \ --query 'Reservations[].Instances[].[InstanceId,IamInstanceProfile.Arn,MetadataOptions.HttpTokens]' --output table
Node identity: IMDSv2 and DHMC
0 / 0One default role for every instance in the Region, and no instance left on IMDSv1. Skip 1.3 to 1.5 if you keep instance profiles, but still do 1.6.
aws ec2 modify-instance-metadata-defaults --http-tokens required --http-put-response-hop-limit 2 aws ec2 get-instance-metadata-defaults
for i in $(aws ec2 describe-instances \
--filters Name=metadata-options.http-tokens,Values=optional Name=instance-state-name,Values=running \
--query 'Reservations[].Instances[].InstanceId' --output text); do
aws ec2 modify-instance-metadata-options --instance-id "$i" --http-tokens required --http-endpoint enabled
donetrust-dhmc.json
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": {"Service": "ssm.amazonaws.com"}, "Action": "sts:AssumeRole"}]
}aws iam create-role --role-name AWSSystemsManagerDefaultEC2InstanceManagementRole --path /service-role/ \ --assume-role-policy-document file://trust-dhmc.json aws iam attach-role-policy --role-name AWSSystemsManagerDefaultEC2InstanceManagementRole \ --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedEC2InstanceDefaultPolicy
DHMC=arn:aws:ssm:$AWS_REGION:$ACCOUNT:servicesetting/ssm/managed-instance/default-ec2-instance-management-role aws ssm update-service-setting --setting-id $DHMC \ --setting-value service-role/AWSSystemsManagerDefaultEC2InstanceManagementRole aws ssm get-service-setting --setting-id $DHMC --query 'ServiceSetting.[SettingValue,Status]'
OLD=arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM for r in $(aws iam list-entities-for-policy --policy-arn $OLD --query 'PolicyRoles[].RoleName' --output text); do aws iam attach-role-policy --role-name "$r" --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore aws iam detach-role-policy --role-name "$r" --policy-arn $OLD echo "fixed $r" done
aws ssm describe-instance-information --query 'InstanceInformationList[].[InstanceId,PingStatus,AgentVersion]' --output table # on a missing node sudo ssm-cli get-diagnostics --output table
Private endpoints
0 / 0Private subnets reach Systems Manager without NAT. The VPC needs DNS support and DNS hostnames turned on.
export EP_SG=$(aws ec2 create-security-group --group-name ssm-endpoints \ --description "Systems Manager interface endpoints" --vpc-id $VPC --query GroupId --output text) aws ec2 authorize-security-group-ingress --group-id $EP_SG --protocol tcp --port 443 --cidr $VPC_CIDR
endpoint-policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "OrgPrincipalsOnly", "Effect": "Allow", "Principal": "*", "Action": "*", "Resource": "*",
"Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-REPLACE_WITH_ORG_ID"}}
}]
}for svc in ssm ssmmessages kms logs; do
aws ec2 create-vpc-endpoint --vpc-id $VPC --vpc-endpoint-type Interface \
--service-name com.amazonaws.$AWS_REGION.$svc --subnet-ids $PRIV_SUBNETS \
--security-group-ids $EP_SG --private-dns-enabled --policy-document file://endpoint-policy.json \
--query 'VpcEndpoint.VpcEndpointId' --output text
doneaws ec2 create-vpc-endpoint --vpc-id $VPC --vpc-endpoint-type Gateway \ --service-name com.amazonaws.$AWS_REGION.s3 --route-table-ids $PRIV_RT
aws ec2 describe-vpc-endpoints --filters Name=vpc-id,Values=$VPC \ --query 'VpcEndpoints[].[ServiceName,VpcEndpointType,State,PrivateDnsEnabled]' --output table # on a node getent hosts ssm.eu-central-1.amazonaws.com ssmmessages.eu-central-1.amazonaws.com
Session key and log destinations
0 / 0One customer managed key encrypts session data, the log bucket and the log group. The key is also a kill switch: disable it and no new encrypted session can start.
key-ssm-sessions.json
{
"Version": "2012-10-17",
"Statement": [
{"Sid": "AccountAdmin", "Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::111122223333:root"},
"Action": "kms:*", "Resource": "*"},
{"Sid": "CloudWatchLogsSessionGroup", "Effect": "Allow",
"Principal": {"Service": "logs.eu-central-1.amazonaws.com"},
"Action": ["kms:Encrypt*", "kms:Decrypt*", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:Describe*"],
"Resource": "*",
"Condition": {"ArnLike": {"kms:EncryptionContext:aws:logs:arn": "arn:aws:logs:eu-central-1:111122223333:log-group:/ssm/sessions"}}}
]
}export KEY=$(aws kms create-key --description "SSM sessions and session logs" \ --policy file://key-ssm-sessions.json --query KeyMetadata.KeyId --output text) export KEY_ARN=$(aws kms describe-key --key-id $KEY --query KeyMetadata.Arn --output text) aws kms create-alias --alias-name alias/ssm-sessions --target-key-id $KEY aws kms enable-key-rotation --key-id $KEY --rotation-period-in-days 365 sed -i "s/REPLACE_WITH_SESSION_KEY_ID/$KEY/g" ./*.json
aws s3api create-bucket --bucket $LOG_BUCKET --create-bucket-configuration LocationConstraint=$AWS_REGION
aws s3api put-public-access-block --bucket $LOG_BUCKET --public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-bucket-encryption --bucket $LOG_BUCKET --server-side-encryption-configuration \
"{\"Rules\":[{\"ApplyServerSideEncryptionByDefault\":{\"SSEAlgorithm\":\"aws:kms\",\"KMSMasterKeyID\":\"$KEY_ARN\"},\"BucketKeyEnabled\":true}]}"
aws s3api put-bucket-versioning --bucket $LOG_BUCKET --versioning-configuration Status=Enabledbucket-tls.json
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "TlsOnly", "Effect": "Deny", "Principal": "*", "Action": "s3:*",
"Resource": ["arn:aws:s3:::ssm-session-logs-111122223333-eu-central-1",
"arn:aws:s3:::ssm-session-logs-111122223333-eu-central-1/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}}
}]
}aws s3api put-bucket-policy --bucket $LOG_BUCKET --policy file://bucket-tls.json
aws logs create-log-group --log-group-name /ssm/sessions --kms-key-id $KEY_ARN aws logs put-retention-policy --log-group-name /ssm/sessions --retention-in-days 400
node-logging.json
{
"Version": "2012-10-17",
"Statement": [
{"Sid": "SessionLogStreams", "Effect": "Allow",
"Action": ["logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogStreams"],
"Resource": "arn:aws:logs:eu-central-1:111122223333:log-group:/ssm/sessions:*"},
{"Sid": "FindLogGroups", "Effect": "Allow", "Action": "logs:DescribeLogGroups", "Resource": "*"},
{"Sid": "WriteLogObjects", "Effect": "Allow", "Action": "s3:PutObject",
"Resource": ["arn:aws:s3:::ssm-session-logs-111122223333-eu-central-1/sessions/*",
"arn:aws:s3:::ssm-session-logs-111122223333-eu-central-1/run-command/*",
"arn:aws:s3:::ssm-session-logs-111122223333-eu-central-1/patch/*"]},
{"Sid": "CheckBucketEncryption", "Effect": "Allow", "Action": "s3:GetEncryptionConfiguration",
"Resource": "arn:aws:s3:::ssm-session-logs-111122223333-eu-central-1"},
{"Sid": "SessionKey", "Effect": "Allow", "Action": ["kms:Decrypt", "kms:GenerateDataKey"],
"Resource": "arn:aws:kms:eu-central-1:111122223333:key/REPLACE_WITH_SESSION_KEY_ID"}
]
}aws iam put-role-policy --role-name AWSSystemsManagerDefaultEC2InstanceManagementRole \ --policy-name ssm-session-logging --policy-document file://node-logging.json
OS baseline by association
0 / 0Before Run As is switched on, the Run As user must exist on every Linux node. ssm-user appears only at the first session, so the association runs daily and catches it later.
os-baseline-linux.json
{
"commands": [
"id ssm-ops >/dev/null 2>&1 || useradd --create-home --shell /bin/bash ssm-ops",
"if [ -f /etc/sudoers.d/ssm-agent-users ]; then echo '#User rules for ssm-user' > /etc/sudoers.d/ssm-agent-users; fi",
"command -v screen >/dev/null || dnf -y install screen || yum -y install screen || apt-get -y install screen"
]
}aws ssm create-association --association-name os-baseline-linux --name AWS-RunShellScript \
--targets Key=tag:ssm-access,Values=ops --schedule-expression "rate(1 day)" \
--parameters file://os-baseline-linux.json --output-location \
"S3Location={OutputS3BucketName=$LOG_BUCKET,OutputS3KeyPrefix=run-command}"os-baseline-windows.json
{
"commands": [
"if (Get-LocalGroupMember -Group Administrators -Member ssm-user -ErrorAction SilentlyContinue) { net localgroup Administrators ssm-user /delete }"
]
}aws ssm create-association --association-name os-baseline-windows --name AWS-RunPowerShellScript \ --targets Key=tag:ssm-access,Values=ops-windows --schedule-expression "rate(1 day)" \ --parameters file://os-baseline-windows.json
aws ssm list-associations --query 'Associations[].[AssociationName,Name,Overview.Status]' --output table aws ssm list-compliance-summaries --filters Key=ComplianceType,Values=Association,Type=EQUAL \ --query 'ComplianceSummaryItems[].[ComplianceType,CompliantSummary.CompliantCount,NonCompliantSummary.NonCompliantCount]'
Session preferences
0 / 0One document per Region. With encryption required and the bucket or group not encrypted, sessions fail; phase 3 prevents that.
SessionManagerRunShell.json
{
"schemaVersion": "1.0",
"description": "Session Manager preferences, hardened",
"sessionType": "Standard_Stream",
"inputs": {
"s3BucketName": "ssm-session-logs-111122223333-eu-central-1",
"s3KeyPrefix": "sessions",
"s3EncryptionEnabled": true,
"cloudWatchLogGroupName": "/ssm/sessions",
"cloudWatchEncryptionEnabled": true,
"cloudWatchStreamingEnabled": true,
"kmsKeyId": "REPLACE_WITH_SESSION_KEY_ID",
"runAsEnabled": true,
"runAsDefaultUser": "ssm-ops",
"idleSessionTimeout": "15",
"maxSessionDuration": "120",
"shellProfile": {
"linux": "umask 027; export TMOUT=900; export HISTTIMEFORMAT='%F %T '; echo 'Authorized use only. This session is recorded.'",
"windows": "Write-Host 'Authorized use only. This session is recorded.'"
}
}
}if aws ssm describe-document --name SSM-SessionManagerRunShell >/dev/null 2>&1; then
VER=$(aws ssm update-document --name SSM-SessionManagerRunShell --content file://SessionManagerRunShell.json \
--document-version '$LATEST' --query DocumentDescription.DocumentVersion --output text)
aws ssm update-document-default-version --name SSM-SessionManagerRunShell --document-version "$VER"
else
aws ssm create-document --name SSM-SessionManagerRunShell --content file://SessionManagerRunShell.json \
--document-type Session --document-format JSON
fiaws ssm get-document --name SSM-SessionManagerRunShell --query Content --output text | jq .inputs
Operator access
0 / 0Shell for the ops team on tagged nodes, a fixed database tunnel for DBAs, and nothing implicit.
aws ec2 create-tags --resources REPLACE_WITH_INSTANCE_IDS --tags Key=ssm-access,Value=ops
ssm-operator-shell.json
{
"Version": "2012-10-17",
"Statement": [
{"Sid": "ShellOnTaggedNodes", "Effect": "Allow", "Action": "ssm:StartSession",
"Resource": ["arn:aws:ec2:*:111122223333:instance/*", "arn:aws:ssm:*:111122223333:managed-instance/*"],
"Condition": {"StringEquals": {"ssm:resourceTag/ssm-access": "ops"},
"BoolIfExists": {"ssm:SessionDocumentAccessCheck": "true"}}},
{"Sid": "ShellDocumentOnly", "Effect": "Allow", "Action": "ssm:StartSession",
"Resource": "arn:aws:ssm:*:111122223333:document/SSM-SessionManagerRunShell",
"Condition": {"BoolIfExists": {"ssm:SessionDocumentAccessCheck": "true"}}},
{"Sid": "OwnSessionsOnly", "Effect": "Allow",
"Action": ["ssm:TerminateSession", "ssm:ResumeSession", "ssmmessages:OpenDataChannel"],
"Resource": "arn:aws:ssm:*:*:session/${aws:userid}-*"},
{"Sid": "SeeNodes", "Effect": "Allow",
"Action": ["ssm:DescribeSessions", "ssm:GetConnectionStatus", "ssm:DescribeInstanceProperties",
"ssm:DescribeInstanceInformation", "ec2:DescribeInstances"],
"Resource": "*"},
{"Sid": "SessionKey", "Effect": "Allow", "Action": "kms:GenerateDataKey",
"Resource": "arn:aws:kms:eu-central-1:111122223333:key/REPLACE_WITH_SESSION_KEY_ID"}
]
}aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY --policy-document file://ssm-operator-shell.json \ --query 'findings[].[findingType,issueCode,findingDetails]' --output table
export SSO_INSTANCE=$(aws sso-admin list-instances --query 'Instances[0].InstanceArn' --output text) export PS_OPS=REPLACE_WITH_OPS_PERMISSION_SET_ARN aws sso-admin put-inline-policy-to-permission-set --instance-arn $SSO_INSTANCE --permission-set-arn $PS_OPS \ --inline-policy file://ssm-operator-shell.json aws sso-admin provision-permission-set --instance-arn $SSO_INSTANCE --permission-set-arn $PS_OPS \ --target-type ALL_PROVISIONED_ACCOUNTS
aws iam tag-role --role-name REPLACE_WITH_BREAKGLASS_ROLE --tags Key=SSMSessionRunAs,Value=ops-breakglass
pf-payments-db.json
{
"schemaVersion": "1.0",
"description": "Port forwarding to the payments database only",
"sessionType": "Port",
"parameters": {
"localPortNumber": {"type": "String", "description": "Local port", "allowedPattern": "^[0-9]{4,5}$", "default": "15432"}
},
"properties": {
"type": "LocalPortForwarding",
"host": "payments-db.cluster-REPLACE.eu-central-1.rds.amazonaws.com",
"portNumber": "5432",
"localPortNumber": "{{ localPortNumber }}"
}
}aws ssm create-document --name Ops-PortForward-PaymentsDb --document-type Session \ --document-format JSON --content file://pf-payments-db.json
ssm-dba-tunnel.json
{
"Version": "2012-10-17",
"Statement": [
{"Sid": "TunnelNodes", "Effect": "Allow", "Action": "ssm:StartSession",
"Resource": "arn:aws:ec2:*:111122223333:instance/*",
"Condition": {"StringEquals": {"ssm:resourceTag/ssm-access": "db-tunnel"},
"BoolIfExists": {"ssm:SessionDocumentAccessCheck": "true"}}},
{"Sid": "PaymentsDbDocumentOnly", "Effect": "Allow", "Action": "ssm:StartSession",
"Resource": "arn:aws:ssm:eu-central-1:111122223333:document/Ops-PortForward-PaymentsDb",
"Condition": {"BoolIfExists": {"ssm:SessionDocumentAccessCheck": "true"}}},
{"Sid": "OwnSessionsOnly", "Effect": "Allow",
"Action": ["ssm:TerminateSession", "ssm:ResumeSession", "ssmmessages:OpenDataChannel"],
"Resource": "arn:aws:ssm:*:*:session/${aws:userid}-*"},
{"Sid": "SessionKey", "Effect": "Allow", "Action": "kms:GenerateDataKey",
"Resource": "arn:aws:kms:eu-central-1:111122223333:key/REPLACE_WITH_SESSION_KEY_ID"}
]
}aws ssm start-session --target REPLACE_WITH_TUNNEL_NODE --document-name Ops-PortForward-PaymentsDb \ --parameters localPortNumber=15432
Just-in-time access for production
0 / 0Needs the unified console with a delegated administrator. Approval policies are created in the console under Manage node access, on the Approval policies tab. Pilot with a few users before removing StartSession from everyone.
permit (
principal in AWS::IdentityStore::Group::"REPLACE_WITH_DEV_GROUP_ID",
action == AWS::SSM::Action::"getTokenForInstanceAccess",
resource
)
when {
resource.hasTag("Environment") && resource.getTag("Environment") == "development"
};ssm-jit-requester.json
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "RequestAndUseAccess", "Effect": "Allow",
"Action": ["ssm:StartAccessRequest", "ssm:GetAccessToken", "ssm:TerminateSession", "ssm:ResumeSession"],
"Resource": "*"
}]
}export PS_PROD=REPLACE_WITH_PROD_PERMISSION_SET_ARN aws sso-admin put-inline-policy-to-permission-set --instance-arn $SSO_INSTANCE --permission-set-arn $PS_PROD \ --inline-policy file://ssm-jit-requester.json aws sso-admin provision-permission-set --instance-arn $SSO_INSTANCE --permission-set-arn $PS_PROD \ --target-type ALL_PROVISIONED_ACCOUNTS
Documents and commands
0 / 0No public documents, logged Automation scripts, and narrow documents in place of raw shell access.
for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
aws ssm update-service-setting --region "$r" \
--setting-id /ssm/documents/console/public-sharing-permission --setting-value Disable
donefor d in $(aws ssm list-documents --filters Key=Owner,Values=Self --query 'DocumentIdentifiers[].Name' --output text); do shared=$(aws ssm describe-document-permission --name "$d" --permission-type Share --query 'AccountIds' --output text) [ -n "$shared" ] && echo "$d -> $shared" done aws ssm modify-document-permission --name REPLACE_WITH_DOCUMENT --permission-type Share --account-ids-to-remove All
S=arn:aws:ssm:$AWS_REGION:$ACCOUNT:servicesetting/ssm/automation aws ssm update-service-setting --setting-id $S/customer-script-log-destination --setting-value CloudWatch aws ssm update-service-setting --setting-id $S/customer-script-log-group-name \ --setting-value /aws/ssm/automation/executeScript
restart-unit.json
{
"schemaVersion": "2.2",
"description": "Restart one allowed systemd unit",
"parameters": {
"unit": {
"type": "String",
"description": "Unit to restart",
"allowedValues": ["nginx", "php-fpm", "amazon-cloudwatch-agent"],
"interpolationType": "ENV_VAR"
}
},
"mainSteps": [{
"action": "aws:runShellScript",
"name": "restart",
"precondition": {"StringEquals": ["platformType", "Linux"]},
"inputs": {"runCommand": ["systemctl restart \"$SSM_unit\"", "systemctl is-active \"$SSM_unit\""]}
}]
}aws ssm create-document --name Ops-RestartUnit --document-type Command --document-format JSON \ --content file://restart-unit.json --tags Key=owner,Value=platform
CMD=$(aws ssm send-command --document-name Ops-RestartUnit --parameters unit=nginx \ --targets Key=tag:Role,Values=web --max-concurrency 10% --max-errors 1 --timeout-seconds 600 \ --output-s3-bucket-name $LOG_BUCKET --output-s3-key-prefix run-command \ --comment "restart nginx, change 1234" --query Command.CommandId --output text) aws ssm list-command-invocations --command-id $CMD --query 'CommandInvocations[].[InstanceId,Status]' --output table
for d in Ops-RestartUnit Ops-PortForward-PaymentsDb; do aws ssm add-tags-to-resource --resource-type Document --resource-id $d --tags Key=owner,Value=platform done
Patching, agent updates, inventory
0 / 0For an organization, create a patch policy in Quick Setup from the delegated administrator and skip 9.3 and 9.4. For one account, the associations and the window below do the same job.
export BL=$(aws ssm create-patch-baseline --name al2023-security --operating-system AMAZON_LINUX_2023 \
--approval-rules "PatchRules=[{PatchFilterGroup={PatchFilters=[{Key=CLASSIFICATION,Values=[Security]},{Key=SEVERITY,Values=[Critical,Important]}]},ApproveAfterDays=7,ComplianceLevel=CRITICAL}]" \
--description "AL2023 security, critical and important, after 7 days" --query BaselineId --output text)
echo $BLaws ssm register-default-patch-baseline --baseline-id $BL aws ssm get-default-patch-baseline --operating-system AMAZON_LINUX_2023
aws ssm create-association --association-name patch-scan-daily --name AWS-RunPatchBaseline \
--targets Key=InstanceIds,Values='*' --parameters Operation=Scan --schedule-expression "rate(1 day)" \
--output-location "S3Location={OutputS3BucketName=$LOG_BUCKET,OutputS3KeyPrefix=patch}"MW=$(aws ssm create-maintenance-window --name patch-install-weekly --schedule "cron(0 3 ? * SUN *)" \
--duration 3 --cutoff 1 --no-allow-unassociated-targets --query WindowId --output text)
MWT=$(aws ssm register-target-with-maintenance-window --window-id $MW --resource-type INSTANCE \
--targets Key=tag:PatchWindow,Values=sun-0300 --query WindowTargetId --output text)
aws ssm register-task-with-maintenance-window --window-id $MW --task-type RUN_COMMAND \
--task-arn AWS-RunPatchBaseline --targets Key=WindowTargetIds,Values=$MWT \
--max-concurrency 20% --max-errors 1 --priority 1 \
--task-invocation-parameters '{"RunCommand":{"Parameters":{"Operation":["Install"],"RebootOption":["RebootIfNeeded"]},"TimeoutSeconds":600}}'aws ssm create-association --association-name agent-update --name AWS-UpdateSSMAgent \ --targets Key=InstanceIds,Values='*' --schedule-expression "rate(14 days)"
aws ssm create-association --association-name inventory --name AWS-GatherSoftwareInventory \ --targets Key=InstanceIds,Values='*' --schedule-expression "rate(12 hours)"
aws ssm list-compliance-summaries \ --query 'ComplianceSummaryItems[].[ComplianceType,CompliantSummary.CompliantCount,NonCompliantSummary.NonCompliantCount]' \ --output table
Parameter Store
0 / 0A key per environment, a path per application, and decryption bound to the parameter ARN.
export PKEY=$(aws kms create-key --description "Parameter Store, production" --query KeyMetadata.KeyId --output text) aws kms create-alias --alias-name alias/ssm-params-prod --target-key-id $PKEY aws kms enable-key-rotation --key-id $PKEY sed -i "s/REPLACE_WITH_PARAM_KEY_ID/$PKEY/g" ./*.json
read -rsp "db password: " DBPW; echo aws ssm put-parameter --name /prod/payments/db/password --type SecureString --key-id alias/ssm-params-prod \ --tier Standard --value "$DBPW" unset DBPW
param-read-payments.json
{
"Version": "2012-10-17",
"Statement": [
{"Sid": "ReadOwnPath", "Effect": "Allow",
"Action": ["ssm:GetParameter", "ssm:GetParameters", "ssm:GetParametersByPath"],
"Resource": ["arn:aws:ssm:eu-central-1:111122223333:parameter/prod/payments",
"arn:aws:ssm:eu-central-1:111122223333:parameter/prod/payments/*"]},
{"Sid": "DecryptOwnPathOnly", "Effect": "Allow", "Action": "kms:Decrypt",
"Resource": "arn:aws:kms:eu-central-1:111122223333:key/REPLACE_WITH_PARAM_KEY_ID",
"Condition": {"StringLike": {"kms:EncryptionContext:PARAMETER_ARN": "arn:aws:ssm:eu-central-1:111122223333:parameter/prod/payments/*"}}}
]
}aws iam put-role-policy --role-name REPLACE_WITH_PAYMENTS_ROLE --policy-name read-payments-params \ --policy-document file://param-read-payments.json
read -rsp "partner token: " TOKEN; echo
aws ssm put-parameter --name /prod/payments/api/partner-token --type SecureString \
--key-id alias/ssm-params-prod --tier Advanced --value "$TOKEN" \
--policies '[{"Type":"Expiration","Version":"1.0","Attributes":{"Timestamp":"2027-01-31T00:00:00.000Z"}},{"Type":"ExpirationNotification","Version":"1.0","Attributes":{"Before":"14","Unit":"Days"}}]'
unset TOKENaws ssm get-service-setting --setting-id /ssm/parameter-store/default-parameter-tier --query ServiceSetting.SettingValue aws ssm get-service-setting --setting-id /ssm/parameter-store/high-throughput-enabled --query ServiceSetting.SettingValue
Organization guardrails
0 / 0From the management account. Create the PlatformSsmAdmin role in each account first, and list the Quick Setup role names your organization uses.
scp-ssm-guardrails.json
{
"Version": "2012-10-17",
"Statement": [
{"Sid": "LockSessionPreferences", "Effect": "Deny",
"Action": ["ssm:CreateDocument", "ssm:UpdateDocument", "ssm:DeleteDocument", "ssm:UpdateDocumentDefaultVersion"],
"Resource": "arn:aws:ssm:*:*:document/SSM-SessionManagerRunShell",
"Condition": {"ArnNotLike": {"aws:PrincipalArn": ["arn:aws:iam::*:role/PlatformSsmAdmin"]}}},
{"Sid": "LockServiceSettings", "Effect": "Deny",
"Action": ["ssm:UpdateServiceSetting", "ssm:ResetServiceSetting"], "Resource": "*",
"Condition": {"ArnNotLike": {"aws:PrincipalArn": ["arn:aws:iam::*:role/PlatformSsmAdmin",
"arn:aws:iam::*:role/AWS-QuickSetup-*"]}}},
{"Sid": "NoSharingOut", "Effect": "Deny", "Action": "ssm:ModifyDocumentPermission", "Resource": "*",
"Condition": {"ArnNotLike": {"aws:PrincipalArn": ["arn:aws:iam::*:role/PlatformSsmAdmin"]}}},
{"Sid": "NoNewActivations", "Effect": "Deny", "Action": "ssm:CreateActivation", "Resource": "*",
"Condition": {"ArnNotLike": {"aws:PrincipalArn": ["arn:aws:iam::*:role/PlatformSsmAdmin"]}}},
{"Sid": "ProtectProdParameters", "Effect": "Deny",
"Action": ["ssm:PutParameter", "ssm:DeleteParameter", "ssm:DeleteParameters", "ssm:LabelParameterVersion"],
"Resource": "arn:aws:ssm:*:*:parameter/prod/*",
"Condition": {"ArnNotLike": {"aws:PrincipalArn": ["arn:aws:iam::*:role/PlatformSsmAdmin",
"arn:aws:iam::*:role/deploy-prod"]}}}
]
}SCP=$(aws organizations create-policy --type SERVICE_CONTROL_POLICY --name ssm-guardrails \ --description "Systems Manager control plane" --content file://scp-ssm-guardrails.json \ --query 'Policy.PolicySummary.Id' --output text) aws organizations attach-policy --policy-id $SCP --target-id REPLACE_WITH_SANDBOX_OU_ID
for pair in ec2-instance-managed-by-systems-manager:EC2_INSTANCE_MANAGED_BY_SSM \
ec2-managedinstance-patch-compliance-status-check:EC2_MANAGEDINSTANCE_PATCH_COMPLIANCE_STATUS_CHECK \
ec2-managedinstance-association-compliance-status-check:EC2_MANAGEDINSTANCE_ASSOCIATION_COMPLIANCE_STATUS_CHECK \
ssm-document-not-public:SSM_DOCUMENT_NOT_PUBLIC; do
aws configservice put-config-rule --config-rule \
"{\"ConfigRuleName\":\"${pair%%:*}\",\"Source\":{\"Owner\":\"AWS\",\"SourceIdentifier\":\"${pair##*:}\"}}"
doneaws securityhub get-findings --filters '{
"ComplianceSecurityControlId":[{"Value":"SSM.","Comparison":"PREFIX"}],
"ComplianceStatus":[{"Value":"FAILED","Comparison":"EQUALS"}],
"RecordState":[{"Value":"ACTIVE","Comparison":"EQUALS"}]}' \
--query 'Findings[].[Compliance.SecurityControlId,Resources[0].Id]' --output tableDetection
0 / 0An alert on anything that weakens the setup, one on unlogged tunnels, and a metric on risky commands inside sessions.
aws cloudtrail describe-trails --query 'trailList[].[Name,IsOrganizationTrail,IsMultiRegionTrail,HomeRegion]' --output table
topic-policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "EventBridgePublish", "Effect": "Allow", "Principal": {"Service": "events.amazonaws.com"},
"Action": "sns:Publish", "Resource": "arn:aws:sns:eu-central-1:111122223333:ssm-alerts"
}]
}export TOPIC=$(aws sns create-topic --name ssm-alerts --query TopicArn --output text) aws sns set-topic-attributes --topic-arn $TOPIC --attribute-name Policy --attribute-value file://topic-policy.json aws sns subscribe --topic-arn $TOPIC --protocol email --notification-endpoint [email protected]
ssm-control-plane.json
{
"source": ["aws.ssm"],
"detail-type": ["AWS API Call via CloudTrail"],
"detail": {
"eventSource": ["ssm.amazonaws.com"],
"eventName": ["UpdateDocument", "UpdateDocumentDefaultVersion", "DeleteDocument",
"UpdateServiceSetting", "ResetServiceSetting", "ModifyDocumentPermission",
"CreateActivation", "DeregisterManagedInstance"]
}
}aws events put-rule --name ssm-control-plane --event-pattern file://ssm-control-plane.json aws events put-targets --rule ssm-control-plane --targets Id=sns,Arn=$TOPIC
ssm-tunnels.json
{
"source": ["aws.ssm"],
"detail-type": ["AWS API Call via CloudTrail"],
"detail": {
"eventSource": ["ssm.amazonaws.com"],
"eventName": ["StartSession"],
"requestParameters": {"documentName": ["AWS-StartPortForwardingSession",
"AWS-StartPortForwardingSessionToRemoteHost", "AWS-StartSSHSession"]}
}
}aws events put-rule --name ssm-tunnels --event-pattern file://ssm-tunnels.json aws events put-targets --rule ssm-tunnels --targets Id=sns,Arn=$TOPIC
aws logs put-metric-filter --log-group-name /ssm/sessions --filter-name imds-in-session \ --filter-pattern '"169.254.169.254"' \ --metric-transformations metricName=ImdsInSession,metricNamespace=SSM/Sessions,metricValue=1 aws cloudwatch put-metric-alarm --alarm-name ssm-imds-in-session --namespace SSM/Sessions \ --metric-name ImdsInSession --statistic Sum --period 300 --evaluation-periods 1 --threshold 1 \ --comparison-operator GreaterThanOrEqualToThreshold --treat-missing-data notBreaching --alarm-actions $TOPIC
aws ssm describe-sessions --state History --max-results 50 \ --query 'Sessions[].[StartDate,Owner,Target,DocumentName,Status]' --output table
Hybrid nodes
0 / 0Only if you manage machines outside EC2. Run 13.1 and 13.2 as PlatformSsmAdmin, because the SCP blocks activations for everyone else. Since 30 September 2026 each session costs $0.05 and each command $0.002 on these nodes.
trust-hybrid.json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow", "Principal": {"Service": "ssm.amazonaws.com"}, "Action": "sts:AssumeRole",
"Condition": {"StringEquals": {"aws:SourceAccount": "111122223333"},
"ArnLike": {"aws:SourceArn": "arn:aws:ssm:eu-central-1:111122223333:*"}}
}]
}aws iam create-role --role-name SSMServiceRole-hybrid --assume-role-policy-document file://trust-hybrid.json aws iam attach-role-policy --role-name SSMServiceRole-hybrid \ --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore aws iam put-role-policy --role-name SSMServiceRole-hybrid --policy-name ssm-session-logging \ --policy-document file://node-logging.json
EXP=$(date -u -d '+2 hours' +%Y-%m-%dT%H:%M:%S) read ACT_ID ACT_CODE <<< "$(aws ssm create-activation --default-instance-name onprem-web01 \ --iam-role SSMServiceRole-hybrid --registration-limit 1 --expiration-date $EXP \ --tags Key=Environment,Value=production Key=ssm-access,Value=ops \ --query '[ActivationId,ActivationCode]' --output text)" echo $ACT_ID
mkdir -p /tmp/ssm && cd /tmp/ssm curl -fsSo ssm-setup-cli https://amazon-ssm-eu-central-1.s3.eu-central-1.amazonaws.com/latest/linux_amd64/ssm-setup-cli chmod +x ssm-setup-cli sudo ./ssm-setup-cli -register -activation-code "REPLACE_WITH_ACT_CODE" -activation-id "REPLACE_WITH_ACT_ID" -region eu-central-1
cd /etc/amazon/ssm [ -f amazon-ssm-agent.json ] || sudo cp amazon-ssm-agent.json.template amazon-ssm-agent.json sudo jq '.Profile.KeyAutoRotateDays = 30' amazon-ssm-agent.json | sudo tee amazon-ssm-agent.json.new >/dev/null sudo mv amazon-ssm-agent.json.new amazon-ssm-agent.json sudo systemctl restart amazon-ssm-agent
aws ssm delete-activation --activation-id $ACT_ID aws ssm describe-activations --query 'ActivationList[].[ActivationId,DefaultInstanceName,Expired,RegistrationsCount]' --output table aws ssm describe-instance-information --filters Key=ResourceType,Values=ManagedInstance \ --query 'InstanceInformationList[].[InstanceId,ComputerName,PingStatus,AgentVersion]' --output table
Verify
0 / 0Prove each layer with a positive and a negative test, and keep the output as evidence.
export TEST_NODE=REPLACE_WITH_INSTANCE_ID aws ssm start-session --target $TEST_NODE --document-name SSM-SessionManagerRunShell
aws s3 ls s3://$LOG_BUCKET/sessions/ | tail -3 aws logs describe-log-streams --log-group-name /ssm/sessions --order-by LastEventTime --descending \ --max-items 3 --query 'logStreams[].logStreamName'
aws ssm start-session --target REPLACE_WITH_TUNNEL_NODE
for s in /ssm/managed-instance/default-ec2-instance-management-role \
/ssm/documents/console/public-sharing-permission \
/ssm/automation/customer-script-log-destination \
/ssm/parameter-store/high-throughput-enabled; do
aws ssm get-service-setting --setting-id "$s" --query 'ServiceSetting.[SettingId,SettingValue]' --output text
donemkdir -p evidence aws ssm get-document --name SSM-SessionManagerRunShell --query Content --output text > evidence/preferences.json aws ssm list-associations --output json > evidence/associations.json aws ssm list-compliance-summaries --output json > evidence/compliance.json aws ec2 describe-vpc-endpoints --filters Name=vpc-id,Values=$VPC --output json > evidence/endpoints.json
Playbooks
For the day something goes wrong. Ticks here do not count toward the runbook progress.
PB1 Node missing or ConnectionLost
when a node drops out of Fleet Manager or start-session says the target is not connected
sudo ssm-cli get-diagnostics --output table sudo systemctl status amazon-ssm-agent sudo tail -50 /var/log/amazon/ssm/errors.log
PB2 Session refused or ends at once
when start-session fails or closes immediately
aws iam simulate-principal-policy --policy-source-arn REPLACE_WITH_ROLE_ARN --action-names ssm:StartSession \ --resource-arns arn:aws:ssm:$AWS_REGION:$ACCOUNT:document/SSM-SessionManagerRunShell \ --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table
PB3 Suspected misuse of sessions or commands
when an alert fires or a transcript shows something wrong
aws ssm describe-sessions --state Active --query 'Sessions[].[SessionId,Owner,Target]' --output table aws ssm terminate-session --session-id REPLACE_WITH_SESSION_ID
aws kms disable-key --key-id $KEY
aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=SendCommand \ --max-results 50 --query 'Events[].[EventTime,Username]' --output table aws ssm list-commands --max-results 50 --query 'Commands[].[CommandId,DocumentName,RequestedDateTime,Status]' --output table
PB4 Activation code leaked
when an activation ID and code appear in a ticket, chat or repository
aws ssm delete-activation --activation-id REPLACE_WITH_ACT_ID aws ssm describe-instance-information --filters Key=ActivationIds,Values=REPLACE_WITH_ACT_ID \ --query 'InstanceInformationList[].[InstanceId,ComputerName,IPAddress,RegistrationDate]' --output table
aws ssm deregister-managed-instance --instance-id REPLACE_WITH_MI_ID
PB5 Session logging was switched off
when the control-plane alert names SSM-SessionManagerRunShell
aws ssm list-document-versions --name SSM-SessionManagerRunShell \ --query 'DocumentVersions[].[DocumentVersion,CreatedDate,IsDefaultVersion]' --output table aws ssm update-document-default-version --name SSM-SessionManagerRunShell --document-version REPLACE_WITH_GOOD_VERSION
Troubleshooting
| Symptom | Cause | Fix | Step |
|---|---|---|---|
| Instances never appear after DHMC | IMDSv1, old agent, or a profile that allows UpdateInstanceInformation | require IMDSv2, update the agent, strip SSM rights from the profile | 1.2, 1.5 |
| Node drops off about an hour after a policy change | ssmmessages:OpenControlChannel removed from the node role | restore AmazonSSMManagedEC2InstanceDefaultPolicy or Core | 1.3 |
| Private node offline, NAT removed | missing ssmmessages endpoint or private DNS off | create it with private DNS; check the endpoint SG | 2.3 |
| Agent updates or patching fail in private subnets | S3 gateway endpoint missing or its policy too tight | add the gateway endpoint; allow the AWS-owned buckets | 2.4 |
| Session fails with an encryption error | bucket or log group unencrypted, or the node cannot use the key | encrypt both; Decrypt and GenerateDataKey for the node role | 3.3, 3.6 |
| Session closes at once on Linux | Run As user missing on that node | run the OS baseline association now | 4.1 |
| No CloudWatch transcript, S3 fine | node role cannot DescribeLogGroups or write to the group | node-logging.json on the role actually in use | 3.6 |
| Transcripts cut short | screen not installed on the image | install it, or let the association do it | 4.1 |
| A user meant for tunnels opens a shell | SessionDocumentAccessCheck missing | add the condition to both StartSession statements | 6.7 |
| Just-in-time request fails for one node | two manual approval policies match its tags, or no policy matches | one manual policy per node; a fallback for all nodes | 7.3 |
| Document parameter rejected | value outside allowedValues or allowedPattern | expected; extend the list through review | 8.4 |
| Nodes non-compliant right after patching | NoReboot left patches pending a restart | reboot in the window, or RebootIfNeeded | 9.4 |
| App gets AccessDenied on a SecureString | Decrypt condition does not match the parameter ARN, or wrong key | check the path in kms:EncryptionContext:PARAMETER_ARN | 10.3 |
| Quick Setup associations fail after the SCP | their role is not in the exceptions | add the role ARN pattern, reattach | 11.2 |
| No alert e-mail | topic policy missing, or subscription not confirmed | set the topic policy; confirm the e-mail | 12.2 |
| Hybrid registration fails | activation expired or used, wrong Region, clock skew | new single-use activation in the right Region; fix NTP | 13.2 |

0 comments