diff --git a/deployment/build_packages.sh b/deployment/build_packages.sh index 2331cff3..171b011a 100755 --- a/deployment/build_packages.sh +++ b/deployment/build_packages.sh @@ -23,7 +23,7 @@ SCRIPT_PATH="$( cd "$(dirname "$0")" ; pwd -P )" PACKAGES_DIR="${SCRIPT_PATH}/packages/" LIBRARY="${SCRIPT_PATH}/../hammer/library" -LAMBDAS="ami-info logs-forwarder ddb-tables-backup sg-issues-identification s3-acl-issues-identification s3-policy-issues-identification iam-keyrotation-issues-identification iam-user-inactive-keys-identification cloudtrails-issues-identification ebs-unencrypted-volume-identification ebs-public-snapshots-identification rds-public-snapshots-identification" +LAMBDAS="ami-info logs-forwarder ddb-tables-backup sg-issues-identification s3-acl-issues-identification s3-policy-issues-identification iam-keyrotation-issues-identification iam-user-inactive-keys-identification cloudtrails-issues-identification ebs-unencrypted-volume-identification ebs-public-snapshots-identification rds-public-snapshots-identification dns-takeover-identification" pushd "${SCRIPT_PATH}" > /dev/null pushd ../hammer/identification/lambdas > /dev/null diff --git a/deployment/cf-templates/ddb.json b/deployment/cf-templates/ddb.json index d8a0d853..6a565d61 100755 --- a/deployment/cf-templates/ddb.json +++ b/deployment/cf-templates/ddb.json @@ -329,6 +329,38 @@ }, "TableName": {"Fn::Join" : ["", [ { "Ref": "ResourcesPrefix" }, "rds-public-snapshots" ] ]} } + }, + "DynamoDBDNSTakeoverDetails": { + "Type": "AWS::DynamoDB::Table", + "DeletionPolicy": "Retain", + "DependsOn": ["DynamoDBCredentials"], + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "account_id", + "AttributeType": "S" + }, + { + "AttributeName": "issue_id", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "account_id", + "KeyType": "HASH" + }, + { + "AttributeName": "issue_id", + "KeyType": "RANGE" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": "10", + "WriteCapacityUnits": "2" + }, + "TableName": {"Fn::Join" : ["", [ { "Ref": "ResourcesPrefix" }, "dns-takeover" ] ]} + } } } } diff --git a/deployment/cf-templates/identification.json b/deployment/cf-templates/identification.json index 2c5204fc..89f1737e 100755 --- a/deployment/cf-templates/identification.json +++ b/deployment/cf-templates/identification.json @@ -164,6 +164,10 @@ "SourceIdentificationRDSSnapshots": { "Type": "String", "Default": "rds-public-snapshots-identification.zip" + }, + "SourceIdentificationDNSTakeover": { + "Type": "String", + "Default": "dns-takeover-identification.zip" } }, "Conditions": { @@ -209,6 +213,9 @@ "IdentificationMetricRDSSnapshotsError": { "value": "RDSSnapshotsError" }, + "IdentificationMetricDNSTakeoverError": { + "value": "DNSTakeoverError" + }, "SNSDisplayNameSecurityGroups": { "value": "describe-security-groups-sns" }, @@ -263,6 +270,12 @@ "SNSTopicNameRDSSnapshots": { "value": "describe-rds-public-snapshots-lambda" }, + "SNSDisplayNameDNSTakeover": { + "value": "describe-dns-takeover-sns" + }, + "SNSTopicNameDNSTakeover": { + "value": "describe-dns-takeover-lambda" + }, "LogsForwarderLambdaFunctionName": { "value": "logs-forwarder" }, @@ -322,6 +335,12 @@ }, "IdentifyRDSSnapshotsLambdaFunctionName": { "value": "describe-rds-public-snapshots" + }, + "InitiateDNSTakeoverLambdaFunctionName": { + "value": "initiate-dns-takeover" + }, + "IdentifyDNSTakeoverLambdaFunctionName": { + "value": "describe-dns-takeover" } } }, @@ -1463,6 +1482,107 @@ "LogGroupName" : { "Ref": "LogGroupLambdaEvaluateRDSSnapshots" } } }, + + "LambdaInitiateDNSTakeoverEvaluation": { + "Type": "AWS::Lambda::Function", + "DependsOn": ["SNSNotifyLambdaEvaluateDNSTakeover", "LogGroupLambdaInitiateDNSTakeoverEvaluation"], + "Properties": { + "Code": { + "S3Bucket": { "Ref": "SourceS3Bucket" }, + "S3Key": { "Ref": "SourceIdentificationDNSTakeover" } + }, + "Environment": { + "Variables": { + "SNS_DNS_TAKEOVER_ARN": { "Ref": "SNSNotifyLambdaEvaluateDNSTakeover" } + } + }, + "Description": "Lambda function for initiate to identify dns takeover issues.", + "FunctionName": {"Fn::Join" : ["", [ { "Ref": "ResourcesPrefix" }, + { "Fn::FindInMap": ["NamingStandards", "InitiateDNSTakeoverLambdaFunctionName", "value"] } ] + ]}, + "Handler": "initiate_to_desc_dns_takeover.lambda_handler", + "MemorySize": 128, + "Timeout": "300", + "Role": {"Fn::Join" : ["", [ "arn:aws:iam::", + { "Ref": "AWS::AccountId" }, + ":role/", + { "Ref": "ResourcesPrefix" }, + { "Ref": "IdentificationIAMRole" } + ] ]}, + "Runtime": "python3.6" + } + }, + "LogGroupLambdaInitiateDNSTakeoverEvaluation": { + "Type" : "AWS::Logs::LogGroup", + "Properties" : { + "LogGroupName": {"Fn::Join": ["", [ "/aws/lambda/", + { "Ref": "ResourcesPrefix" }, + { "Fn::FindInMap": ["NamingStandards", + "InitiateDNSTakeoverLambdaFunctionName", + "value"] + } ] ] }, + "RetentionInDays": "7" + } + }, + "SubscriptionFilterLambdaInitiateDNSTakeoverEvaluation": { + "Type" : "AWS::Logs::SubscriptionFilter", + "DependsOn": ["LambdaLogsForwarder", + "PermissionToInvokeLambdaLogsForwarderCloudWatchLogs", + "LogGroupLambdaInitiateDNSTakeoverEvaluation"], + "Properties" : { + "DestinationArn" : { "Fn::GetAtt" : [ "LambdaLogsForwarder", "Arn" ] }, + "FilterPattern" : "[level != START && level != END && level != DEBUG, ...]", + "LogGroupName" : { "Ref": "LogGroupLambdaInitiateDNSTakeoverEvaluation" } + } + }, + + "LambdaEvaluateDNSTakeover": { + "Type": "AWS::Lambda::Function", + "DependsOn": ["LogGroupLambdaEvaluateDNSTakeover"], + "Properties": { + "Code": { + "S3Bucket": { "Ref": "SourceS3Bucket" }, + "S3Key": { "Ref": "SourceIdentificationDNSTakeover" } + }, + "Description": "Lambda function to describe dns takeover issues.", + "FunctionName": {"Fn::Join" : ["", [ { "Ref": "ResourcesPrefix" }, + { "Fn::FindInMap": ["NamingStandards", "IdentifyDNSTakeoverLambdaFunctionName", "value"] } ] + ]}, + "Handler": "describe_dns_takeover.lambda_handler", + "MemorySize": 128, + "Timeout": "300", + "Role": {"Fn::Join" : ["", [ "arn:aws:iam::", + { "Ref": "AWS::AccountId" }, + ":role/", + { "Ref": "ResourcesPrefix" }, + { "Ref": "IdentificationIAMRole" } + ] ]}, + "Runtime": "python3.6" + } + }, + "LogGroupLambdaEvaluateDNSTakeover": { + "Type" : "AWS::Logs::LogGroup", + "Properties" : { + "LogGroupName": {"Fn::Join": ["", [ "/aws/lambda/", + { "Ref": "ResourcesPrefix" }, + { "Fn::FindInMap": ["NamingStandards", + "IdentifyDNSTakeoverLambdaFunctionName", + "value"] + } ] ] }, + "RetentionInDays": "7" + } + }, + "SubscriptionFilterLambdaEvaluateDNSTakeover": { + "Type" : "AWS::Logs::SubscriptionFilter", + "DependsOn": ["LambdaLogsForwarder", + "PermissionToInvokeLambdaLogsForwarderCloudWatchLogs", + "LogGroupLambdaEvaluateDNSTakeover"], + "Properties" : { + "DestinationArn" : { "Fn::GetAtt" : [ "LambdaLogsForwarder", "Arn" ] }, + "FilterPattern" : "[level != START && level != END && level != DEBUG, ...]", + "LogGroupName" : { "Ref": "LogGroupLambdaEvaluateDNSTakeover" } + } + }, "EventBackupDDB": { "Type": "AWS::Events::Rule", @@ -1592,6 +1712,23 @@ } }, + "EventInitiateEvaluationDNSTakeover": { + "Type": "AWS::Events::Rule", + "DependsOn": ["LambdaInitiateDNSTakeoverEvaluation"], + "Properties": { + "Description": "Hammer ScheduledRule to initiate DNS Takeover evaluations", + "Name": {"Fn::Join" : ["", [{ "Ref": "ResourcesPrefix" }, "InitiateEvaluationDNSTakeover"] ] }, + "ScheduleExpression": {"Fn::Join": ["", [ "cron(", "35 ", { "Ref": "IdentificationCheckRateExpression" }, ")" ] ]}, + "State": "ENABLED", + "Targets": [ + { + "Arn": { "Fn::GetAtt": ["LambdaInitiateDNSTakeoverEvaluation", "Arn"] }, + "Id": "LambdaInitiateDNSTakeoverEvaluation" + } + ] + } + }, + "PermissionToInvokeLambdaLogsForwarderCloudWatchLogs": { "Type": "AWS::Lambda::Permission", "DependsOn": ["LambdaLogsForwarder"], @@ -1704,6 +1841,16 @@ "SourceArn": { "Fn::GetAtt": ["EventInitiateEvaluationRDSSnapshots", "Arn"] } } }, + "PermissionToInvokeLambdaInitiateDNSTakeoverEvaluationCloudWatchEvents": { + "Type": "AWS::Lambda::Permission", + "DependsOn": ["LambdaInitiateDNSTakeoverEvaluation", "EventInitiateEvaluationDNSTakeover"], + "Properties": { + "FunctionName": { "Ref": "LambdaInitiateDNSTakeoverEvaluation" }, + "Action": "lambda:InvokeFunction", + "Principal": "events.amazonaws.com", + "SourceArn": { "Fn::GetAtt": ["EventInitiateEvaluationDNSTakeover", "Arn"] } + } + }, "SNSNotifyLambdaEvaluateSG": { "Type": "AWS::SNS::Topic", @@ -1868,6 +2015,25 @@ } }, + "SNSNotifyLambdaEvaluateDNSTakeover": { + "Type": "AWS::SNS::Topic", + "DependsOn": "LambdaEvaluateDNSTakeover", + "Properties": { + "DisplayName": {"Fn::Join" : ["", [ { "Ref": "ResourcesPrefix" }, + { "Fn::FindInMap": ["NamingStandards", "SNSDisplayNameDNSTakeover", "value"] } ] + ]}, + "TopicName": {"Fn::Join" : ["", [ { "Ref": "ResourcesPrefix" }, + { "Fn::FindInMap": ["NamingStandards", "SNSTopicNameDNSTakeover", "value"] } ] + ]}, + "Subscription": [{ + "Endpoint": { + "Fn::GetAtt": ["LambdaEvaluateDNSTakeover", "Arn"] + }, + "Protocol": "lambda" + }] + } + }, + "PermissionToInvokeLambdaEvaluateSgSNS": { "Type": "AWS::Lambda::Permission", "DependsOn": ["SNSNotifyLambdaEvaluateSG", "LambdaEvaluateSG"], @@ -1959,6 +2125,17 @@ } }, + "PermissionToInvokeLambdaEvaluateDNSTakeoverSNS": { + "Type": "AWS::Lambda::Permission", + "DependsOn": ["SNSNotifyLambdaEvaluateDNSTakeover", "LambdaEvaluateDNSTakeover"], + "Properties": { + "Action": "lambda:InvokeFunction", + "Principal": "sns.amazonaws.com", + "SourceArn": { "Ref": "SNSNotifyLambdaEvaluateDNSTakeover" }, + "FunctionName": { "Fn::GetAtt": ["LambdaEvaluateDNSTakeover", "Arn"] } + } + }, + "SNSIdentificationErrors": { "Type": "AWS::SNS::Topic", "Properties": { @@ -2423,6 +2600,52 @@ "Threshold": 0, "TreatMissingData": "notBreaching" } + }, + "AlarmErrorsLambdaInitiateDNSTakeoverEvaluation": { + "Type": "AWS::CloudWatch::Alarm", + "DependsOn": ["SNSIdentificationErrors", "LambdaInitiateDNSTakeoverEvaluation"], + "Properties": { + "AlarmActions": [ { "Ref": "SNSIdentificationErrors" } ], + "OKActions": [ { "Ref": "SNSIdentificationErrors" } ], + "AlarmName": {"Fn::Join": ["/", [ { "Ref": "LambdaInitiateDNSTakeoverEvaluation" }, "LambdaError" ] ]}, + "EvaluationPeriods": 1, + "Namespace": "AWS/Lambda", + "MetricName": "Errors", + "Dimensions": [ + { + "Name": "FunctionName", + "Value": { "Ref": "LambdaInitiateDNSTakeoverEvaluation" } + } + ], + "Period": 3600, + "Statistic": "Maximum", + "ComparisonOperator" : "GreaterThanThreshold", + "Threshold": 0, + "TreatMissingData": "notBreaching" + } + }, + "AlarmErrorsLambdaDNSTakeoverEvaluation": { + "Type": "AWS::CloudWatch::Alarm", + "DependsOn": ["SNSIdentificationErrors", "LambdaEvaluateDNSTakeover"], + "Properties": { + "AlarmActions": [ { "Ref": "SNSIdentificationErrors" } ], + "OKActions": [ { "Ref": "SNSIdentificationErrors" } ], + "AlarmName": {"Fn::Join": ["/", [ { "Ref": "LambdaEvaluateDNSTakeover" }, "LambdaError" ] ]}, + "EvaluationPeriods": 1, + "Namespace": "AWS/Lambda", + "MetricName": "Errors", + "Dimensions": [ + { + "Name": "FunctionName", + "Value": { "Ref": "LambdaEvaluateDNSTakeover" } + } + ], + "Period": 3600, + "Statistic": "Maximum", + "ComparisonOperator" : "GreaterThanThreshold", + "Threshold": 0, + "TreatMissingData": "notBreaching" + } } }, "Outputs": { diff --git a/deployment/configs/config.json b/deployment/configs/config.json index fdbc1f25..d5a817c0 100755 --- a/deployment/configs/config.json +++ b/deployment/configs/config.json @@ -118,5 +118,17 @@ "reporting": false, "remediation": false, "remediation_retention_period": 0 + }, + "dns_takeover": { + "enabled": true, + "ddb.table_name": "djif-hammer-dns-takeover", + "take_over_criteria_days": 30, + "reporting": true, + "remediation": false, + "remediation_retention_period": 21 + }, + "cnameRecordsets":{ + "enabled": true, + "matching_record_sets": ["wsj.com", "marketwatch.com", "barrons.com"] } } diff --git a/deployment/configs/whitelist.json b/deployment/configs/whitelist.json index 4d50bd96..c505f5ec 100755 --- a/deployment/configs/whitelist.json +++ b/deployment/configs/whitelist.json @@ -35,5 +35,8 @@ "rds_public_snapshot":{ "__comment__": "Detects public RDS snapshots (with 'all' in 'restore' attribute). Key - account id, values - snapshot ARNs.", "123456789012": ["arn:aws:rds:eu-central-1:123456789012:snapshot:public", "arn:aws:rds:eu-west-1:123456789012:snapshot:rds:snapshot1"] + }, + "dns_takeover":{ + } } \ No newline at end of file diff --git a/deployment/terraform/modules/identification/identification.tf b/deployment/terraform/modules/identification/identification.tf index 3c2a2796..cd128a18 100755 --- a/deployment/terraform/modules/identification/identification.tf +++ b/deployment/terraform/modules/identification/identification.tf @@ -11,7 +11,8 @@ resource "aws_cloudformation_stack" "identification" { "aws_s3_bucket_object.iam-user-inactive-keys-identification", "aws_s3_bucket_object.cloudtrails-issues-identification", "aws_s3_bucket_object.ebs-unencrypted-volume-identification", - "aws_s3_bucket_object.ebs-public-snapshots-identification" + "aws_s3_bucket_object.ebs-public-snapshots-identification", + "aws_s3_bucket_object.dns-takeover-identification" ] tags = "${var.tags}" @@ -34,6 +35,7 @@ resource "aws_cloudformation_stack" "identification" { SourceIdentificationEBSVolumes = "${aws_s3_bucket_object.ebs-unencrypted-volume-identification.id}" SourceIdentificationEBSSnapshots = "${aws_s3_bucket_object.ebs-public-snapshots-identification.id}" SourceIdentificationRDSSnapshots = "${aws_s3_bucket_object.rds-public-snapshots-identification.id}" + SourceIdentificationDNSTakeover = "${aws_s3_bucket_object.dns-takeover-identification.id}" } template_url = "https://${var.s3bucket}.s3.amazonaws.com/${aws_s3_bucket_object.identification-cfn.id}" diff --git a/deployment/terraform/modules/identification/sources.tf b/deployment/terraform/modules/identification/sources.tf index 43d214f0..b23e3f90 100755 --- a/deployment/terraform/modules/identification/sources.tf +++ b/deployment/terraform/modules/identification/sources.tf @@ -68,4 +68,10 @@ resource "aws_s3_bucket_object" "rds-public-snapshots-identification" { bucket = "${var.s3bucket}" key = "lambda/${format("rds-public-snapshots-identification-%s.zip", "${md5(file("${path.module}/../../../packages/rds-public-snapshots-identification.zip"))}")}" source = "${path.module}/../../../packages/rds-public-snapshots-identification.zip" +} + +resource "aws_s3_bucket_object" "dns-takeover-identification" { + bucket = "${var.s3bucket}" + key = "lambda/${format("dns-takeover-identification-%s.zip", "${md5(file("${path.module}/../../../packages/dns-takeover-identification.zip"))}")}" + source = "${path.module}/../../../packages/dns-takeover-identification.zip" } \ No newline at end of file diff --git a/hammer/identification/lambdas/dns-takeover-identification/describe_dns_takeover.py b/hammer/identification/lambdas/dns-takeover-identification/describe_dns_takeover.py new file mode 100644 index 00000000..d534d557 --- /dev/null +++ b/hammer/identification/lambdas/dns-takeover-identification/describe_dns_takeover.py @@ -0,0 +1,79 @@ +import json +import logging + +from datetime import datetime, timezone +from library.logger import set_logging +from library.config import Config +from library.aws.utility import Account +from library.aws.s3 import S3Operations + + +def lambda_handler(event, context): + """ Lambda handler to cname record_sets details.""" + set_logging(level=logging.DEBUG) + + try: + payload = json.loads(event["Records"][0]["Sns"]["Message"]) + account_id = payload['account_id'] + account_name = payload['account_name'] + except Exception: + logging.exception(f"Failed to parse event\n{event}") + return + + try: + config = Config() + + main_account = Account(region=config.aws.region) + + backup_bucket = config.aws.s3_backup_bucket + + account = Account(id=account_id, + name=account_name, + role_name=config.aws.role_name_identification) + if account.session is None: + return + + matching_record_sets = config.cnameRecordsets.matching_record_sets + + s3client = main_account.client("s3"), + upload_cname_record_sets(s3client, account, backup_bucket, matching_record_sets) + + except Exception: + logging.exception(f"Failed to get record_sets for '{account_id} ({account_name})'") + return + + logging.debug(f"Completed record_set for '{account_id} ({account_name})'") + + +def upload_cname_record_sets(s3_client, account, bucket, matching_record_sets): + resulted_record_sets = {} + client = account.client("route53") + + hosted_zones_res = client.list_hosted_zones() + for hosted_zone in hosted_zones_res["HostedZones"]: + id = hosted_zone["Id"] + resource_record_sets = client.list_resource_record_sets( + HostedZoneId=id + ) + record_sets = {} + for resource_record_set in resource_record_sets["ResourceRecordSets"]: + name = resource_record_set["Name"] + type = resource_record_set["Type"] + + if type == "CNAME": + for record_set in matching_record_sets: + if record_set in name: + record_sets[record_set] = resource_record_set["ResourceRecords"] + if bool(record_sets): + resulted_record_sets[id] = record_sets + + if bool(resulted_record_sets): + timestamp = datetime.now(timezone.utc).isoformat('T', 'seconds') + # this prefix MUST match prefix in find_source_s3 + path = f"hosted_zones/{account.id}/{id}_{timestamp}.json" + if S3Operations.object_exists(s3_client, bucket, path): + raise Exception(f"s3://{bucket}/{path} already exists") + S3Operations.put_object(s3_client, bucket, path, resulted_record_sets) + + + diff --git a/hammer/identification/lambdas/dns-takeover-identification/initiate_to_desc_dns_takeover.py b/hammer/identification/lambdas/dns-takeover-identification/initiate_to_desc_dns_takeover.py new file mode 100644 index 00000000..0cad1908 --- /dev/null +++ b/hammer/identification/lambdas/dns-takeover-identification/initiate_to_desc_dns_takeover.py @@ -0,0 +1,29 @@ +import os +import logging + +from library.logger import set_logging +from library.config import Config +from library.aws.utility import Sns + + +def lambda_handler(event, context): + """ Lambda handler to Initiating to get CNAME recordsets """ + set_logging(level=logging.DEBUG) + logging.debug("Initiating to get CNAME recordsets") + + try: + sns_arn = os.environ["SNS_DNS_TAKEOVER_ARN"] + config = Config() + + logging.debug("Iterating over each account to get cname recordsets") + for account_id, account_name in config.cnameRecordsets.accounts.items(): + payload = {"account_id": account_id, + "account_name": account_name, + } + logging.debug(f"Initiating to get CNAME recordsets for account'{account_name}'") + Sns.publish(sns_arn, payload) + except Exception: + logging.exception("Error occurred while initiation of retrieving CNAME recordsets") + return + + logging.debug("CNAME recordsets retrieving initiation done") diff --git a/hammer/library/aws/dns.py b/hammer/library/aws/dns.py new file mode 100644 index 00000000..2891ace9 --- /dev/null +++ b/hammer/library/aws/dns.py @@ -0,0 +1,195 @@ +import logging + +from collections import namedtuple +from library.utility import timeit + +from botocore.exceptions import ClientError + + +# structure which describes hosted zones +HosteZones = namedtuple('HostedZone', [ + # hosted zone id + 'id', + # hosted zone name + 'name', + # hosted zone type (public or private) + 'type', + # list with record set + 'cname_record_set' + ]) + +class DNSOperations(object): + + @classmethod + @timeit + def get_dns_hosted_zone_details(cls, dns_client, domain_name): + """ Retrieve domain's hosted zone details along with record set + + :param dns_client: boto3 route53 client + :param domain_name: dns name + + :return: list with hosted zone details + """ + # describe hosted zones with dns name. + response = dns_client.list_hosted_zones() + hosted_zone_details = [] + if "HostedZones" in response: + for hosted_zone in response["HostedZones"]: + id = hosted_zone["Id"] + name= hosted_zone["Name"] + if domain_name in name: + type = hosted_zone["Config"]["PrivateZone"] + cname_record_set_list = [] + record_set_response = dns_client.list_resource_record_sets( + HostedZoneId=id + ) + if "ResourceRecordSets" in record_set_response: + for resource_record_set in record_set_response["ResourceRecordSets"]: + if resource_record_set["Type"] == "CNAME": + for record_set in resource_record_set["ResourceRecords"]: + cname_record_set_list.append(record_set["Value"]) + + hosted_zone = HosteZones( + id=id, + name=name, + type=type, + cname_record_set=cname_record_set_list + ) + hosted_zone_details.append(hosted_zone) + + return hosted_zone_details + + + @staticmethod + def renew_domain(dns_client, domain_name, year): + """ + Renew the domain with duration. + + :param dns_client: Route53Domain boto3 client + :param domain_name: domain name which needs to be renew + :param year: current year of the domain expiry. + + :return: nothing + """ + """dns_client.renew_domain( + DomainName=domain_name, + DurationInYears=2, + CurrentExpiryYear=year + )""" + + dns_client.enable_domain_auto_renew( + DomainName=domain_name + ) + + +class Rout53Domain(object): + """ + Basic class for Route53 Domain. + Encapsulates domain auto_renew, expiry date. + """ + def __init__(self, account, name, expiry_date, auto_renew, now, takeover_criteria): + """ + :param account: `Account` instance where domain + + :param name: `Name` of domain name + :param expiry_date: Domain expiration date + :param auto_renew: Domain auto_renew flag + :param now: corrent date + :param takeover_criteria: expiration validate days. + """ + self.account = account + self.name =name + self.auto_renew = auto_renew + self.expiry_date = expiry_date + self.now = now + self.takeover_days = takeover_criteria + + + def __str__(self): + return f"{self.__class__.__name__}(Name={self.name}, IsExpiry={self.validate_expiry})" + + @property + def validate_expiry(self): + """ + :return: boolean, True - if domain is going to expiry in 1 month + """ + return (self.expiry_date - self.now).days < self.takeover_days + + def renew_domain(self): + """ + Renew the domain. + + :return: nothing + + """ + try: + DNSOperations.renew_domain(self.account.client("route53domains"), self.name, self.now.year) + except Exception: + logging.exception(f"Failed to renew domain {self.name}") + return False + + return True + + +class DNSTakeoverChecker(object): + """ + Basic class for checking Domain name expiry details in account. + Encapsulates discovered Domains. + """ + def __init__(self, account, now=None, takeover_criteria=None): + """ + :param account: `Account` instance with Domain name to check + """ + self.account = account + self.now = now + self.takeover_criteria_days = takeover_criteria + self.domains = [] + + def get_domain(self, name): + """ + :return: `Domain` by name + """ + for domain in self.domains: + if domain.name == name: + return domain + return None + + def check(self, domains=None): + """ + Walk through Domains in the account and check them (expiry soon or not). + Put all gathered domains to `self.domains`. + + :param domains: list with Domains names to check, if it is not supplied - all domains must be checked + + :return: boolean. True - if check was successful, + False - otherwise + """ + try: + # AWS does not support filtering dirung list, so get all domains for account + response = self.account.client("route53domains").list_domains() + except ClientError as err: + if err.response['Error']['Code'] in ["AccessDenied", "UnauthorizedOperation"]: + logging.error(f"Access denied in {self.account} " + f"(dns:{err.operation_name})") + else: + logging.exception(f"Failed to list domains in {self.account}") + return False + + if "Domains" in response: + for domain in response["Domains"]: + domain_name = domain["DomainName"] + expiry_date = domain["Expiry"] + auto_renew = domain["AutoRenew"] + + if domains is not None and domain_name not in domains: + continue + + if not auto_renew: + domain = Rout53Domain(account=self.account, + name=domain_name, + expiry_date=expiry_date, + auto_renew= auto_renew, + now = self.now, + takeover_criteria = self.takeover_criteria_days) + self.domains.append(domain) + return True diff --git a/hammer/library/config.py b/hammer/library/config.py index 4704f3c8..d8910f47 100755 --- a/hammer/library/config.py +++ b/hammer/library/config.py @@ -56,6 +56,10 @@ def __init__(self, self.ebsSnapshot = ModuleConfig(self._config, "ebs_public_snapshot") # RDS public snapshot issue config self.rdsSnapshot = ModuleConfig(self._config, "rds_public_snapshot") + # DNS takeover issue config + self.dnsTakeover = ModuleConfig(self._config, "dns_takeover") + + self.cnameRecordsets = ModuleConfig(self._config, "cnameRecordsets") self.bu_list = self._config.get("bu_list", []) diff --git a/hammer/library/ddb_issues.py b/hammer/library/ddb_issues.py index 05a49785..55cbd4be 100755 --- a/hammer/library/ddb_issues.py +++ b/hammer/library/ddb_issues.py @@ -208,6 +208,10 @@ class IAMKeyInactiveIssue(Issue): def __init__(self, *args): super().__init__(*args) +class DNSTakeoverIssue(Issue): + def __init__(self, *args): + super().__init__(*args) + class Operations(object): @staticmethod diff --git a/hammer/reporting-remediation/remediation/clean_dns_takeover.py b/hammer/reporting-remediation/remediation/clean_dns_takeover.py new file mode 100644 index 00000000..3bd98263 --- /dev/null +++ b/hammer/reporting-remediation/remediation/clean_dns_takeover.py @@ -0,0 +1,130 @@ +""" +Class to remediate Domain expiry details. +""" +import sys +import logging + + +from library.logger import set_logging, add_cw_logging +from library.config import Config +from library.jiraoperations import JiraReporting +from library.slack_utility import SlackNotification +from library.ddb_issues import Operations as IssueOperations +from library.ddb_issues import DNSTakeoverIssue +from library.aws.dns import DNSTakeoverChecker +from library.aws.utility import Account +from library.utility import SingletonInstance, SingletonInstanceException + + +class CleanDNSTakeoverIssues: + """ Class to remediate DNS takeover issues """ + def __init__(self, config): + self.config = config + + def clean_dns_takeover(self): + """ Class method to clean Route53 domains which are violating aws best practices """ + main_account = Account(region=config.aws.region) + ddb_table = main_account.resource("dynamodb").Table(self.config.dnsTakeover.ddb_table_name) + + retention_period = self.config.dnsTakeover.remediation_retention_period + + jira = JiraReporting(self.config) + slack = SlackNotification(self.config) + + for account_id, account_name in self.config.aws.accounts.items(): + logging.debug(f"Checking '{account_name} / {account_id}'") + issues = IssueOperations.get_account_open_issues(ddb_table, account_id, DNSTakeoverIssue) + for issue in issues: + dns_name = issue.issue_id + + in_whitelist = self.config.dnsTakeover.in_whitelist(account_id, dns_name) + #in_fixlist = self.config.dnsTakeover.in_fixnow(account_id, dns_name) + + if in_whitelist: + logging.debug(f"Skipping {dns_name} (in whitelist)") + continue + # if not in_fixlist: + # logging.debug(f"Skipping {dns_name} (not in fixlist)") + # continue + + if issue.timestamps.reported is None: + logging.debug(f"Skipping '{dns_name}' (was not reported)") + continue + + if issue.timestamps.remediated is not None: + logging.debug(f"Skipping {dns_name} (has been already remediated)") + continue + + updated_date = issue.timestamp_as_datetime + no_of_days_issue_created = (self.config.now - updated_date).days + + if no_of_days_issue_created >= retention_period: + + + try: + account = Account(id=account_id, + name=account_name, + region=issue.issue_details.region, + role_name=self.config.aws.role_name_reporting) + if account.session is None: + continue + + checker = DNSTakeoverChecker(account=account) + checker.check(domains=[dns_name]) + domain = checker.get_domain(dns_name) + if domain is None: + logging.debug(f"Domain {dns_name} was removed by user") + elif not domain.validate_expiry: + logging.debug(f"Domain {domain.name} takeover issue was remediated by user") + else: + logging.debug(f"Remediating '{domain.name}' takeover issue") + + remediation_succeed = True + if domain.renew_domain(): + comment = (f"Domain '{domain.name}' takeover issue " + f"in '{account_name} / {account_id}' account " + f"was remediated by hammer") + else: + remediation_succeed = False + comment = (f"Failed to remediate domain '{domain.name}' takeover issue " + f"in '{account_name} / {account_id}' account " + f"due to some limitations. Please, check manually") + + jira.remediate_issue( + ticket_id=issue.jira_details.ticket, + comment=comment, + reassign=remediation_succeed, + ) + slack.report_issue( + msg=f"{comment}" + f"{' (' + jira.ticket_url(issue.jira_details.ticket) + ')' if issue.jira_details.ticket else ''}", + account_id=account_id + ) + IssueOperations.set_status_remediated(ddb_table, issue) + except Exception: + logging.exception(f"Error occurred while updating domain '{dns_name}' " + f"in '{account_name} / {account_id}'") + else: + logging.debug(f"Skipping '{dns_name}' " + f"({retention_period - no_of_days_issue_created} days before remediation)") + + +if __name__ == "__main__": + module_name = sys.modules[__name__].__loader__.name + set_logging(level=logging.DEBUG, logfile=f"/var/log/hammer/{module_name}.log") + config = Config() + add_cw_logging(config.local.log_group, + log_stream=module_name, + level=logging.DEBUG, + region=config.aws.region) + try: + si = SingletonInstance(module_name) + except SingletonInstanceException: + logging.error(f"Another instance of '{module_name}' is already running, quitting") + sys.exit(1) + + try: + class_object = CleanDNSTakeoverIssues(config) + class_object.clean_dns_takeover() + except Exception: + logging.exception("Failed to clean DSN takeover issues") \ No newline at end of file diff --git a/hammer/reporting-remediation/reporting/create_dns_takeover_issue_tickets.py b/hammer/reporting-remediation/reporting/create_dns_takeover_issue_tickets.py new file mode 100644 index 00000000..88ebc42d --- /dev/null +++ b/hammer/reporting-remediation/reporting/create_dns_takeover_issue_tickets.py @@ -0,0 +1,183 @@ +""" +Class to create DNS takeover issue tickets. +""" +import sys +import logging + + +from library.logger import set_logging, add_cw_logging +from library.config import Config +from library.aws.utility import Account +from library.utility import list_converter +from library.jiraoperations import JiraReporting +from library.slack_utility import SlackNotification +from library.aws.dns import DNSOperations +from library.ddb_issues import IssueStatus, DNSTakeoverIssue +from library.ddb_issues import Operations as IssueOperations +from library.utility import SingletonInstance, SingletonInstanceException + + +class CreateDNSTakeoverIssueTickets: + """ Class to create DNS takeover issue tickets """ + def __init__(self, config): + self.config = config + + def build_hosted_zones_table(self, hosted_zones): + hosed_zone_details = "" + + if len(hosted_zones): + hosed_zone_details += f"List of Hosted zone details with CNAME record sets: \n" + hosed_zone_details += ( + f"||Hosted Zone ID||Name" + f"||Is Private|CNAME Recordsets||\n") + + for hosted_zone in hosted_zones: + hosed_zone_details += ( + f"|{hosted_zone.id}|{hosted_zone.name}|{hosted_zone.type}" + f"|{list_converter(hosted_zone.cname_record_set)}|\n" + ) + + return hosed_zone_details + + def create_tickets_dns_takeover(self): + """ Class method to create jira tickets """ + table_name = self.config.dnsTakeover.ddb_table_name + + main_account = Account(region=self.config.aws.region) + ddb_table = main_account.resource("dynamodb").Table(table_name) + jira = JiraReporting(self.config) + slack = SlackNotification(self.config) + + for account_id, account_name in self.config.aws.accounts.items(): + logging.debug(f"Checking '{account_name} / {account_id}'") + issues = IssueOperations.get_account_not_closed_issues(ddb_table, account_id, DNSTakeoverIssue) + for issue in issues: + dns_name = issue.issue_id + # issue has been already reported + if issue.timestamps.reported is not None: + owner = issue.issue_details.owner + bu = issue.jira_details.business_unit + product = issue.jira_details.product + + if issue.status in [IssueStatus.Resolved, IssueStatus.Whitelisted]: + logging.debug(f"Closing {issue.status.value} Domain name '{dns_name}'") + + comment = (f"Closing {issue.status.value} Domain name '{dns_name}' " + f"in '{account_name} / {account_id}' account ") + jira.close_issue( + ticket_id=issue.jira_details.ticket, + comment=comment + ) + slack.report_issue( + msg=f"{comment}" + f"{' (' + jira.ticket_url(issue.jira_details.ticket) + ')' if issue.jira_details.ticket else ''}", + owner=owner, + account_id=account_id, + bu=bu, product=product, + ) + IssueOperations.set_status_closed(ddb_table, issue) + # issue.status != IssueStatus.Closed (should be IssueStatus.Open) + elif issue.timestamps.updated > issue.timestamps.reported: + logging.debug(f"Updating Domain name '{dns_name}'") + + comment = "Issue details are changed, please check again.\n" + jira.update_issue( + ticket_id=issue.jira_details.ticket, + comment=comment + ) + slack.report_issue( + msg=f"Domain name '{dns_name}' issue is changed " + f"in '{account_name} / {account_id}' account" + f"{' (' + jira.ticket_url(issue.jira_details.ticket) + ')' if issue.jira_details.ticket else ''}", + owner=owner, + account_id=account_id, + bu=bu, product=product, + ) + IssueOperations.set_status_updated(ddb_table, issue) + else: + logging.debug(f"No changes for '{dns_name}'") + # issue has not been reported yet + else: + logging.debug(f"Reporting domain name '{dns_name}' dns takeover issue") + + bu = None + + if bu is None: + bu = self.config.get_bu_by_name(dns_name) + + issue_summary = (f"DNS name '{dns_name}' is going expiry " + f"in '{account_name} / {account_id}' account{' [' + bu + ']' if bu else ''}") + + issue_description = f"Domain Name is going to expiry.\n\n" + + auto_remediation_date = (self.config.now + self.config.dnsTakeover.issue_retention_date).date() + issue_description += f"\n{{color:red}}*Auto-Remediation Date*: {auto_remediation_date}{{color}}\n\n" + + issue_description += (f"*Risk*: High\n\n" + f"*Account Name*: {account_name}\n" + f"*Account ID*: {account_id}\n" + f"*Domain name*: {dns_name}\n\n") + + account = Account(id=account_id, + name=account_name, + region=self.config.aws.region, + role_name=self.config.aws.role_name_reporting) + + dns_client = account.client("route53") if account.session is not None else None + hosted_zone_details = None + + if dns_client is not None: + hosted_zones = DNSOperations.get_dns_hosted_zone_details(dns_client, dns_name) + + hosted_zone_details = self.build_hosted_zones_table(hosted_zones) + + issue_description += f"{hosted_zone_details if hosted_zone_details else ''}" + + issue_description += f"\n" + issue_description += ( + f"*Recommendation*: " + f"Renew the Domain and update Autorenew option as true.") + + try: + response = jira.add_issue( + issue_summary=issue_summary, issue_description=issue_description, + priority="Major", labels=["dns-takeover"], + account_id=account_id + ) + except Exception: + logging.exception("Failed to create jira ticket") + continue + + if response is not None: + issue.jira_details.ticket = response.ticket_id + issue.jira_details.ticket_assignee_id = response.ticket_assignee_id + + + slack.report_issue( + msg=f"Discovered {issue_summary}" + f"{' (' + jira.ticket_url(issue.jira_details.ticket) + ')' if issue.jira_details.ticket else ''}", + account_id=account_id, + ) + + IssueOperations.set_status_reported(ddb_table, issue) + + +if __name__ == '__main__': + module_name = sys.modules[__name__].__loader__.name + set_logging(level=logging.DEBUG, logfile=f"/var/log/hammer/{module_name}.log") + config = Config() + add_cw_logging(config.local.log_group, + log_stream=module_name, + level=logging.DEBUG, + region=config.aws.region) + try: + si = SingletonInstance(module_name) + except SingletonInstanceException: + logging.error(f"Another instance of '{module_name}' is already running, quitting") + sys.exit(1) + + try: + obj = CreateDNSTakeoverIssueTickets(config) + obj.create_tickets_dns_takeover() + except Exception: + logging.exception("Failed to create DNS takeover tickets")