├── .gitignore ├── README.md ├── app.py ├── arch.png ├── cdk.json ├── lambda.py ├── requirements.txt └── source.bat /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | package-lock.json 3 | __pycache__ 4 | .pytest_cache 5 | .env 6 | *.egg-info 7 | 8 | # CDK asset staging directory 9 | .cdk.staging 10 | cdk.out 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # SSH-Restricted 3 | 4 | ## SSH-Restricted deploys an SSH compliance rule with auto-remediation via AWS Lambda if SSH access is public. 5 | 6 | 7 | 8 | * SSH-Auto-Restricted checks incoming SSH traffic configurations for security groups using [AWS Config rule](https://docs.aws.amazon.com/config/latest/developerguide/restricted-ssh.html). 9 | * The rule is COMPLIANT when IP addresses of the incoming SSH traffic in the security groups are restricted (CIDR other than 0.0.0.0/0) 10 | * This rule applies only to IPv4. 11 | * If a security group is changed with SSH traffic CIDR equal to 0.0.0.0/0, the AWS Config rule becomes NON_COMPLIANT 12 | * The NON_COMPLIANT event triggers an Eventbridge rule which triggers an AWS Lambda function that removes the SSH incoming traffic 13 | 14 | ### Architecture diagram of the app. 15 | 16 | ![](arch.png) 17 | 18 | 19 | ## Deploying the App to AWS Cloud 20 | 21 | ### Install CDK 22 | 23 | ``` 24 | $ npm install -g aws-cdk 25 | ``` 26 | 27 | ### Create Python Virtual Environment 28 | 29 | ```bash 30 | python -m venv .venv 31 | source .venv/bin/activate 32 | ``` 33 | 34 | ### Install Python-specific modules 35 | 36 | ```bash 37 | pip install -r requirements.txt 38 | ``` 39 | 40 | ### Create Cloudformation from CDK 41 | 42 | ```bash 43 | cdk synth 44 | ``` 45 | 46 | ### Deploy 47 | 48 | ```bash 49 | cdk deploy 50 | ``` 51 | 52 | ## Useful commands 53 | 54 | * `cdk ls` list all stacks in the app 55 | * `cdk synth` emits the synthesized CloudFormation template 56 | * `cdk deploy` deploy this stack to your default AWS account/region 57 | * `cdk diff` compare deployed stack with current state 58 | * `cdk docs` open CDK documentation 59 | 60 | Enjoy! 61 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | from aws_cdk import ( 4 | aws_iam as iam, 5 | aws_events as events, 6 | aws_lambda as lambda_, 7 | aws_config as config, 8 | aws_events_targets as targets, 9 | aws_cloudtrail as trail, 10 | aws_s3 as s3, 11 | core, 12 | ) 13 | 14 | 15 | class SshRestrictedStack(core.Stack): 16 | def __init__(self, app: core.App, id: str) -> None: 17 | super().__init__(app, id) 18 | 19 | # Setting up a role to represent config service principal 20 | aws_role = iam.Role( 21 | self, 22 | 'ConfigRole', 23 | assumed_by=iam.ServicePrincipal('config.amazonaws.com') 24 | ) 25 | 26 | # Adding a managed policy to the above role 27 | aws_role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name("service-role/AWSConfigRole")) 28 | 29 | # Setting up ConfigurationRecorder for AWS Config 30 | aws_config_recorder = config.CfnConfigurationRecorder( 31 | self, 32 | 'ConfigRecorder', 33 | role_arn=aws_role.role_arn, 34 | recording_group={"allSupported": True} 35 | ) 36 | 37 | # Setting up the S3 bucket for Config to deliver the changes 38 | aws_config_bucket = s3.Bucket(self, 'ConfigBucket') 39 | 40 | # Adding policies to the S3 bucket 41 | aws_config_bucket.add_to_resource_policy(iam.PolicyStatement( 42 | effect=iam.Effect.ALLOW, 43 | principals=[aws_role], 44 | resources=[aws_config_bucket.bucket_arn], 45 | actions=["s3:GetBucketAcl", "s3:ListBucket"] 46 | )) 47 | 48 | cst_resource = 'AWSLogs/' + core.Stack.of(self).account + '/Config/*' 49 | 50 | aws_config_bucket.add_to_resource_policy(iam.PolicyStatement( 51 | effect=iam.Effect.ALLOW, 52 | principals=[aws_role], 53 | resources=[aws_config_bucket.arn_for_objects(cst_resource)], 54 | actions=["s3:PutObject"], 55 | conditions={"StringEquals": { 56 | "s3:x-amz-acl": "bucket-owner-full-control"}} 57 | )) 58 | 59 | # Creating the deliverchannel for Config 60 | config.CfnDeliveryChannel( 61 | self, 62 | 'ConfigDeliveryChannel', 63 | s3_bucket_name=aws_config_bucket.bucket_name 64 | ) 65 | 66 | # Create CloulTrail trail 67 | trail.Trail(self, 'Trail') 68 | 69 | # Create Config managed rule 70 | aws_config_managed_rule = config.ManagedRule( 71 | self, 72 | "restricted-ssh", 73 | identifier=config.ManagedRuleIdentifiers.EC2_SECURITY_GROUPS_INCOMING_SSH_DISABLED 74 | ) 75 | 76 | # You cant create a rule if recorder is not enabled 77 | aws_config_managed_rule.node.add_dependency(aws_config_recorder) 78 | 79 | # Event pattern triggered by change in the AWS Config compliance rule 80 | dtl = """{ 81 | "requestParameters": { 82 | "evaluations": { 83 | "complianceType": [ 84 | "NON_COMPLIANT" 85 | ] 86 | } 87 | }, 88 | "additionalEventData": { 89 | "managedRuleIdentifier": [ 90 | "INCOMING_SSH_DISABLED" 91 | ] 92 | } 93 | }""" 94 | # detail needs to be a JSON object 95 | detail = json.loads(dtl) 96 | 97 | # Create an eventbridge rule to be triggered by AWS Config 98 | aws_event_rule = events.Rule( 99 | self, 100 | "Rule", 101 | description='rule that triggers a lambda function to revoke SSH public access directly after AWS Config NON COMFORM event', 102 | event_pattern=events.EventPattern( 103 | detail=detail, 104 | source=["aws.config"] 105 | ) 106 | ) 107 | 108 | # Create role for the lambda function 109 | aws_lambda_se_group_role = iam.Role( 110 | self, 111 | 'aws_lambda_security_group_role', 112 | assumed_by=iam.ServicePrincipal('lambda.amazonaws.com'), 113 | managed_policies=[ 114 | iam.ManagedPolicy.from_aws_managed_policy_name("service-role/AWSLambdaBasicExecutionRole") 115 | ]) 116 | 117 | # Add policy to Lambda role 118 | aws_lambda_se_group_role.add_to_policy(iam.PolicyStatement( 119 | effect=iam.Effect.ALLOW, 120 | resources=["*"], 121 | actions=["ec2:RevokeSecurityGroupIngress", "config:GetComplianceDetailsByConfigRule", "sts:GetCallerIdentity", "ec2:DescribeSecurityGroups"])) 122 | 123 | # Create lambda function and pass it the above role 124 | with open("lambda.py", encoding="utf8") as fp: 125 | handler_code = fp.read() 126 | 127 | aws_lambda_fn = lambda_.Function( 128 | self, "revoke-ssh-access", 129 | role=aws_lambda_se_group_role, 130 | code=lambda_.InlineCode(handler_code), 131 | handler="index.lambda_handler", 132 | timeout=core.Duration.seconds(300), 133 | runtime=lambda_.Runtime.PYTHON_3_7, 134 | ) 135 | 136 | # Add environment variable for lambda function 137 | aws_lambda_fn.add_environment("SSH_RULE_NAME", aws_config_managed_rule.config_rule_name) 138 | 139 | # Adding the lambda function as a target of the rule 140 | aws_event_rule.add_target(targets.LambdaFunction(aws_lambda_fn)) 141 | 142 | 143 | app = core.App() 144 | SshRestrictedStack(app, "SshRestrictedStack") 145 | app.synth() 146 | -------------------------------------------------------------------------------- /arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhorn/ssh-restricted/649d43609b0088981bac6fb57747529fcffd8025/arch.png -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "python3 app.py" 3 | } 4 | -------------------------------------------------------------------------------- /lambda.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import os 3 | 4 | SSH_RULE_NAME = os.environ['SSH_RULE_NAME'] 5 | 6 | 7 | def lambda_handler(event, context): 8 | 9 | boto3.client('sts').get_caller_identity()['Account'] 10 | config_client = boto3.client('config') 11 | 12 | ec2_client = boto3.client('ec2') 13 | 14 | non_compliant_detail = config_client.get_compliance_details_by_config_rule( 15 | ConfigRuleName=SSH_RULE_NAME, 16 | ComplianceTypes=['NON_COMPLIANT'], 17 | Limit=100 18 | ) 19 | results = non_compliant_detail['EvaluationResults'] 20 | if len(results) > 0: 21 | print('None compliant resources with ' + SSH_RULE_NAME) 22 | for sec_group in results: 23 | sec_group_id = sec_group['EvaluationResultIdentifier']['EvaluationResultQualifier']['ResourceId'] 24 | rsp = ec2_client.describe_security_groups(GroupIds=[sec_group_id]) 25 | for sg in rsp['SecurityGroups']: 26 | for ip in sg['IpPermissions']: 27 | if 'FromPort' in ip and ip['FromPort'] == 22: 28 | for cidr in ip['IpRanges']: 29 | if cidr['CidrIp'] == '0.0.0.0/0': 30 | print("Revoking public access for " + sec_group_id) 31 | ec2_client.revoke_security_group_ingress( 32 | GroupId=sec_group_id, IpPermissions=[ip] 33 | ) 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aws-cdk.aws-events 2 | aws-cdk.aws-iam 3 | aws-cdk.aws-config 4 | aws-cdk.aws-events-targets 5 | aws-cdk.aws-lambda 6 | aws-cdk.aws-cloudtrail 7 | aws-cdk.core 8 | aws-cdk.aws-s3 9 | -------------------------------------------------------------------------------- /source.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | rem The sole purpose of this script is to make the command 4 | rem 5 | rem source .env/bin/activate 6 | rem 7 | rem (which activates a Python virtualenv on Linux or Mac OS X) work on Windows. 8 | rem On Windows, this command just runs this batch file (the argument is ignored). 9 | rem 10 | rem Now we don't need to document a Windows command for activating a virtualenv. 11 | 12 | echo Executing .env\Scripts\activate.bat for you 13 | .env\Scripts\activate.bat 14 | --------------------------------------------------------------------------------