feat(regions): Filter Audited Regions (-f) (#1202)

* feat(filter-regions): Added -f and ebs encryption check.

* feat(filter-regions): Added -f and ebs encryption check.

* feat(regional_clients): add regional_clients.

* fix(global variables): created global variables

* chore(role option): Mixed -A/-R option including error handling

* fix(arn): import errors from error.py file

* fix(review_comments): Review PR comments.

Co-authored-by: sergargar <sergio@verica.io>
Co-authored-by: n4ch04 <nachor1992@gmail.com>
This commit is contained in:
Sergio Garcia
2022-06-20 11:25:26 +02:00
committed by GitHub
parent f694a6d12a
commit b89b883741
16 changed files with 30264 additions and 96 deletions

View File

@@ -5,3 +5,9 @@ prowler_version = "3.0-alfa"
# Groups
groups_file = "groups.json"
# AWS services-regions matrix json
aws_services_json_url = (
"https://api.regional-table.region-services.aws.a2z.com/index.json"
)
aws_services_json_file = "providers/aws/aws_regions_services.json"

45
lib/arn/arn.py Normal file
View File

@@ -0,0 +1,45 @@
from arnparse import arnparse
from lib.arn.error import (
RoleArnParsingEmptyResource,
RoleArnParsingFailedMissingFields,
RoleArnParsingIAMRegionNotEmpty,
RoleArnParsingInvalidAccountID,
RoleArnParsingInvalidResourceType,
RoleArnParsingPartitionEmpty,
RoleArnParsingServiceNotIAM,
)
def arn_parsing(arn):
# check for number of fields, must be six
if len(arn.split(":")) != 6:
raise RoleArnParsingFailedMissingFields
else:
arn_parsed = arnparse(arn)
# First check if region is empty (in IAM arns region is always empty)
if arn_parsed.region != None:
raise RoleArnParsingIAMRegionNotEmpty
else:
# check if needed fields are filled:
# - partition
# - service
# - account_id
# - resource_type
# - resource
if arn_parsed.partition == None:
raise RoleArnParsingPartitionEmpty
elif arn_parsed.service != "iam":
raise RoleArnParsingServiceNotIAM
elif (
arn_parsed.account_id == None
or len(arn_parsed.account_id) != 12
or not arn_parsed.account_id.isnumeric()
):
raise RoleArnParsingInvalidAccountID
elif arn_parsed.resource_type != "role":
raise RoleArnParsingInvalidResourceType
elif arn_parsed.resource == "":
raise RoleArnParsingEmptyResource
else:
return arn_parsed

43
lib/arn/error.py Normal file
View File

@@ -0,0 +1,43 @@
class RoleArnParsingFailedMissingFields(Exception):
# The arn contains a numberof fields different than six separated by :"
def __init__(self):
self.message = "The assumed role arn contains a number of fields different than six separated by :, please input a valid arn"
super().__init__(self.message)
class RoleArnParsingIAMRegionNotEmpty(Exception):
# The arn contains a non-empty value for region, since it is an IAM arn is not valid
def __init__(self):
self.message = "The assumed role arn contains a non-empty value for region, since it is an IAM arn is not valid, please input a valid arn"
super().__init__(self.message)
class RoleArnParsingPartitionEmpty(Exception):
# The arn contains an empty value for partition
def __init__(self):
self.message = "The assumed role arn does not contain a value for partition, please input a valid arn"
super().__init__(self.message)
class RoleArnParsingServiceNotIAM(Exception):
def __init__(self):
self.message = "The assumed role arn contains a value for service distinct than iam, please input a valid arn"
super().__init__(self.message)
class RoleArnParsingInvalidAccountID(Exception):
def __init__(self):
self.message = "The assumed role arn contains a value for account id empty or invalid, a valid account id must be composed of 12 numbers, please input a valid arn"
super().__init__(self.message)
class RoleArnParsingInvalidResourceType(Exception):
def __init__(self):
self.message = "The assumed role arn contains a value for resource type different than role, please input a valid arn"
super().__init__(self.message)
class RoleArnParsingEmptyResource(Exception):
def __init__(self):
self.message = "The assumed role arn does not contain a value for resource, please input a valid arn"
super().__init__(self.message)

View File

@@ -3,6 +3,7 @@ import pkgutil
from abc import ABC, abstractmethod
from dataclasses import dataclass
from types import ModuleType
from colorama import Fore, Style
from config.config import groups_file
from lib.logger import logger
@@ -126,7 +127,7 @@ def recover_modules_from_provider(provider: str, service: str = None) -> list:
def run_check(check):
print(f"\nCheck Name: {check.CheckName}")
print(f"\nCheck Name: {check.CheckName} - {Fore.MAGENTA}{check.ServiceName}{Fore.YELLOW}[{check.Severity}]{Style.RESET_ALL}")
logger.debug(f"Executing check: {check.CheckName}")
findings = check.execute()
report(findings)

View File

@@ -2,6 +2,7 @@ from colorama import Fore, Style
def report(check_findings):
check_findings.sort(key=lambda x: x.region)
for finding in check_findings:
color = set_report_color(finding.status)
print(

View File

@@ -6,6 +6,7 @@ from boto3 import session
from botocore.credentials import RefreshableCredentials
from botocore.session import get_session
from lib.arn.arn import arn_parsing
from lib.logger import logger
@@ -20,20 +21,19 @@ class AWS_Credentials:
@dataclass
class Input_Data:
profile: str
role_name: str
account_to_assume: str
role_arn: str
session_duration: int
external_id: str
regions: list
@dataclass
class AWS_Assume_Role:
role_name: str
account_to_assume: str
role_arn: str
session_duration: int
external_id: str
sts_session: session
caller_identity: str
partition: str
@dataclass
@@ -106,7 +106,6 @@ class AWS_Provider:
def validate_credentials(validate_session):
try:
validate_credentials_client = validate_session.client("sts")
caller_identity = validate_credentials_client.get_caller_identity()
@@ -118,36 +117,61 @@ def validate_credentials(validate_session):
def provider_set_session(session_input):
# global variables that are going to be shared accross the project
global aws_session
global original_session
global audited_regions
global audited_partition
global audited_account
assumed_session = None
# Initialize a session info dataclass only with info about the profile
session_info = AWS_Session_Info(
session_input.profile,
None,
None,
)
# Create an global original session using only profile/basic credentials info
original_session = AWS_Provider(session_info).get_session()
logger.info("Validating credentials ...")
# Verificate if we have valid credentials
caller_identity = validate_credentials(original_session)
role_info = AWS_Assume_Role(
session_input.role_name,
session_input.account_to_assume,
session_input.session_duration,
session_input.external_id,
original_session,
caller_identity,
)
logger.info("Credentials validated")
logger.info(f"Original caller identity UserId : {caller_identity['UserId']}")
logger.info(f"Original caller identity ARN : {caller_identity['Arn']}")
if session_input.role_name and session_input.account_to_assume:
logger.info(
f"Assuming role {role_info.role_name} in account {role_info.account_to_assume}"
# Set some global values for original session
audited_regions = session_input.regions
audited_account = caller_identity["Account"]
audited_partition = arnparse(caller_identity["Arn"]).partition
if session_input.role_arn:
# Check if role arn is valid
try:
# this returns the arn already parsed, calls arnparse, into a dict to be used when it is needed to access its fields
role_arn_parsed = arn_parsing(session_input.role_arn)
except Exception as error:
logger.critical(f"{error.__class__.__name__} -- {error}")
quit()
# Set info for role assumption if needed
role_info = AWS_Assume_Role(
session_input.role_arn,
session_input.session_duration,
session_input.external_id,
original_session,
audited_partition,
)
logger.info(f"Assuming role {role_info.role_arn}")
# Assume the role
assumed_role_response = assume_role(role_info)
logger.info("Role assumed")
# Set the info needed to create a session with an assumed role
session_info = AWS_Session_Info(
session_input.profile,
AWS_Credentials(
@@ -160,26 +184,33 @@ def provider_set_session(session_input):
),
role_info,
)
assumed_session = AWS_Provider(session_info).get_session()
aws_session = AWS_Provider(session_info).get_session()
if assumed_session:
aws_session = assumed_session
audited_account = role_arn_parsed.account_id
audited_partition = role_arn_parsed.partition
else:
aws_session = original_session
def assume_role(role_info):
try:
# set the info to assume the role from the partition, account and role name
sts_client = role_info.sts_session.client("sts")
arn_caller_identity = arnparse(role_info.caller_identity["Arn"])
role_arn = f"arn:{arn_caller_identity.partition}:iam::{role_info.account_to_assume}:role/{role_info.role_name}"
# If external id, set it to the assume role api call
if role_info.external_id:
assumed_credentials = sts_client.assume_role(
RoleArn=role_arn,
RoleArn=role_info.role_arn,
RoleSessionName="ProwlerProSession",
DurationSeconds=role_info.session_duration,
ExternalId=role_info.external_id,
)
# else assume the role without the external id
else:
assumed_credentials = sts_client.assume_role(
RoleArn=role_arn,
RoleArn=role_info.role_arn,
RoleSessionName="ProwlerProSession",
DurationSeconds=role_info.session_duration,
)

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,58 @@
{
"Categories": [
"cat1",
"cat2"
],
"CheckAlias": "extra740",
"CheckID": "ec2_ebs_snapshots_encrypted",
"CheckName": "ec2_ebs_snapshots_encrypted",
"CheckTitle": "Check if EBS snapshots are encrypted",
"CheckType": "Data Protection",
"Compliance": [
{
"Control": [
"4.4"
],
"Framework": "CIS-AWS",
"Group": [
"level1",
"level2"
],
"Version": "1.4"
}
],
"DependsOn": [
"othercheck1",
"othercheck2"
],
"Description": "If Security groups are not properly configured the attack surface is increased.",
"Notes": "additional information",
"Provider": "aws",
"RelatedTo": [
"othercheck3",
"othercheck4"
],
"RelatedUrl": "https://serviceofficialsiteorpageforthissubject",
"Remediation": {
"Code": {
"NativeIaC": "code or URL to the code location.",
"Terraform": "code or URL to the code location.",
"cli": "cli command or URL to the cli command location.",
"other": "cli command or URL to the cli command location."
},
"Recommendation": {
"Text": "Run sudo yum update and cross your fingers and toes.",
"Url": "https://myfp.com/recommendations/dangerous_things_and_how_to_fix_them.html"
}
},
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"ResourceType": "AwsIamAccessAnalyzer",
"Risk": "Risk associated.",
"ServiceName": "ec2",
"Severity": "low",
"SubServiceName": "accessanalyzer",
"Tags": {
"Tag1Key": "value",
"Tag2Key": "value"
}
}

View File

@@ -0,0 +1,35 @@
from lib.check.check import Check, Check_Report
from providers.aws.services.ec2.ec2_service import ec2_client
class ec2_ebs_snapshots_encrypted(Check):
def execute(self):
findings = []
for regional_client in ec2_client.regional_clients:
region = regional_client.region
if hasattr(regional_client, "snapshots"):
if regional_client.snapshots:
for snapshot in regional_client.snapshots:
if snapshot["Encrypted"]:
report = Check_Report()
report.status = "PASS"
report.result_extended = (
f"EBS Snapshot {snapshot['SnapshotId']} is encrypted"
)
report.region = region
else:
report = Check_Report()
report.status = "FAIL"
report.result_extended = (
f"EBS Snapshot {snapshot['SnapshotId']} is unencrypted"
)
report.region = region
else:
report = Check_Report()
report.status = "PASS"
report.result_extended = "There are no EC2 EBS snapshots"
report.region = region
findings.append(report)
return findings

View File

@@ -0,0 +1,117 @@
import json
import threading
import urllib.request
from config.config import aws_services_json_file, aws_services_json_url
from lib.logger import logger
from lib.utils.utils import open_file, parse_json_file
from providers.aws.aws_provider import (
audited_account,
audited_partition,
audited_regions,
aws_session,
)
################## EC2
class EC2:
def __init__(self, aws_session, audited_regions):
self.service = "ec2"
self.aws_session = aws_session
self.regional_clients = self.__generate_regional_clients__(
self.service, audited_regions
)
self.__threading_call__(self.__describe_snapshots__)
def __get_clients__(self):
return self.clients
def __get_session__(self):
return self.aws_session
def __generate_regional_clients__(self, service, audited_regions):
regional_clients = []
try: # Try to get the list online
with urllib.request.urlopen(aws_services_json_url) as url:
data = json.loads(url.read().decode())
except:
# Get the list locally
f = open_file(aws_services_json_file)
data = parse_json_file(f)
for att in data["prices"]:
if audited_regions: # Check for input aws audited_regions
if (
service in att["id"].split(":")[0]
and att["attributes"]["aws:region"] in audited_regions
): # Check if service has this region
region = att["attributes"]["aws:region"]
regional_client = aws_session.client(service, region_name=region)
regional_client.region = region
regional_clients.append(regional_client)
else:
if audited_partition in "aws":
if (
service in att["id"].split(":")[0]
and "gov" not in att["attributes"]["aws:region"]
and "cn" not in att["attributes"]["aws:region"]
):
region = att["attributes"]["aws:region"]
regional_client = aws_session.client(
service, region_name=region
)
regional_client.region = region
regional_clients.append(regional_client)
elif audited_partition in "cn":
if (
service in att["id"].split(":")[0]
and "cn" in att["attributes"]["aws:region"]
):
region = att["attributes"]["aws:region"]
regional_client = aws_session.client(
service, region_name=region
)
regional_client.region = region
regional_clients.append(regional_client)
elif audited_partition in "gov":
if (
service in att["id"].split(":")[0]
and "gov" in att["attributes"]["aws:region"]
):
region = att["attributes"]["aws:region"]
regional_client = aws_session.client(
service, region_name=region
)
regional_client.region = region
regional_clients.append(regional_client)
return regional_clients
def __threading_call__(self, call):
threads = []
for regional_client in self.regional_clients:
threads.append(threading.Thread(target=call, args=(regional_client,)))
for t in threads:
t.start()
for t in threads:
t.join()
def __describe_snapshots__(self, regional_client):
logger.info("EC2 - Describing Snapshots...")
try:
describe_snapshots_paginator = regional_client.get_paginator(
"describe_snapshots"
)
snapshots = []
for page in describe_snapshots_paginator.paginate(
OwnerIds=[audited_account]
):
for snapshot in page["Snapshots"]:
snapshots.append(snapshot)
except Exception as error:
logger.error(f"{error.__class__.__name__} -- {error}")
else:
regional_client.snapshots = snapshots
ec2_client = EC2(aws_session, audited_regions)

View File

@@ -22,18 +22,18 @@ class iam_disable_30_days_credentials(Check):
)
if time_since_insertion.days > maximum_expiration_days:
report.status = "FAIL"
report.result_extended = f"User {user['UserName']} has not logged into the console in the past 90 days"
report.result_extended = f"User {user['UserName']} has not logged into the console in the past 30 days"
report.region = "us-east-1"
else:
report.status = "PASS"
report.result_extended = f"User {user['UserName']} has logged into the console in the past 90 days"
report.result_extended = f"User {user['UserName']} has logged into the console in the past 30 days"
report.region = "us-east-1"
except KeyError:
pass
else:
report.status = "PASS"
report.result_extended = (
f"User {user['UserName']} has not console password"
f"User {user['UserName']} has not a console password or is unused."
)
report.region = "us-east-1"
@@ -46,4 +46,4 @@ class iam_disable_30_days_credentials(Check):
report.region = "us-east-1"
findings.append(report)
return findings
return findings

View File

@@ -7,14 +7,13 @@ maximum_expiration_days = 90
class iam_disable_90_days_credentials(Check):
def execute(self):
def execute(self) -> Check_Report:
findings = []
report = Check_Report
response = iam_client.users
if response:
for user in response:
report = Check_Report
report = Check_Report()
if "PasswordLastUsed" in user and user["PasswordLastUsed"] != "":
try:
time_since_insertion = (
@@ -34,13 +33,16 @@ class iam_disable_90_days_credentials(Check):
else:
report.status = "PASS"
report.result_extended = (
f"User {user['UserName']} has not console password"
f"User {user['UserName']} has not a console password or is unused."
)
report.region = "us-east-1"
# Append report
findings.append(report)
else:
report = Check_Report()
report.status = "PASS"
report.result_extended = "There is no IAM users"
report.region = "us-east-1"
return findings
return findings

View File

@@ -23,13 +23,8 @@ class IAM:
def __get_roles__(self):
try:
get_roles_paginator = self.client.get_paginator("list_roles")
except botocore.exceptions.ClientError as error:
logger.error(
f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}"
)
except Exception as error:
logger.critical(f"{error.__class__.__name__} -- {error}")
quit()
logger.error(f"{error.__class__.__name__} -- {error}")
else:
roles = []
for page in get_roles_paginator.paginate():
@@ -43,13 +38,8 @@ class IAM:
while not report_is_completed:
try:
report_status = self.client.generate_credential_report()
except botocore.exceptions.ClientError as error:
logger.error(
f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}"
)
except Exception as error:
logger.critical(f"{error.__class__.__name__} -- {error}")
quit()
logger.error(f"{error.__class__.__name__} -- {error}")
else:
if report_status["State"] == "COMPLETE":
report_is_completed = True
@@ -59,13 +49,8 @@ class IAM:
def __get_groups__(self):
try:
get_groups_paginator = self.client.get_paginator("list_groups")
except botocore.exceptions.ClientError as error:
logger.error(
f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}"
)
except Exception as error:
logger.critical(f"{error.__class__.__name__} -- {error}")
quit()
logger.error(f"{error.__class__.__name__} -- {error}")
else:
groups = []
for page in get_groups_paginator.paginate():
@@ -79,13 +64,8 @@ class IAM:
get_customer_managed_policies_paginator = self.client.get_paginator(
"list_policies"
)
except botocore.exceptions.ClientError as error:
logger.error(
f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}"
)
except Exception as error:
logger.critical(f"{error.__class__.__name__} -- {error}")
quit()
logger.error(f"{error.__class__.__name__} -- {error}")
else:
customer_managed_policies = []
for page in get_customer_managed_policies_paginator.paginate(Scope="Local"):
@@ -97,13 +77,8 @@ class IAM:
def __get_users__(self):
try:
get_users_paginator = self.client.get_paginator("list_users")
except botocore.exceptions.ClientError as error:
logger.error(
f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}"
)
except Exception as error:
logger.critical(f"{error.__class__.__name__} -- {error}")
quit()
logger.error(f"{error.__class__.__name__} -- {error}")
else:
users = []
for page in get_users_paginator.paginate():

View File

@@ -51,14 +51,7 @@ if __name__ == "__main__":
"--role",
nargs="?",
default=None,
help="Role name to be assumed in account passed with -A",
)
parser.add_argument(
"-A",
"--account",
nargs="?",
default=None,
help="AWS account id where the role passed by -R is assumed",
help="ARN of the role to be assumed",
)
parser.add_argument(
"-T",
@@ -75,6 +68,12 @@ if __name__ == "__main__":
default=None,
help="External ID to be passed when assuming role",
)
parser.add_argument(
"-f",
"--filter-region",
nargs="+",
help="AWS region names to run Prowler against",
)
# Parse Arguments
args = parser.parse_args()
@@ -84,38 +83,19 @@ if __name__ == "__main__":
services = args.services
groups = args.groups
checks_file = args.checks_file
# Set Logger
logger.setLevel(logging_levels.get(args.log_level))
# Role assumption input options tests
if args.role or args.account:
if not args.account:
logger.critical(
"It is needed to input an Account Id to assume the role (-A option) when an IAM Role is provided with -R"
)
quit()
elif not args.role:
logger.critical(
"It is needed to input an IAM Role name (-R option) when an Account Id is provided with -A"
)
quit()
if args.session_duration not in range(900, 43200):
logger.critical("Value for -T option must be between 900 and 43200")
quit()
if args.session_duration != 3600 or args.external_id:
if not args.account or not args.role:
logger.critical("To use -I/-T options both -A and -R options are needed")
if not args.role:
logger.critical("To use -I/-T options -R option is needed")
quit()
session_input = Input_Data(
profile=args.profile,
role_name=args.role,
account_to_assume=args.account,
session_duration=args.session_duration,
external_id=args.external_id,
)
# Set Logger
logger.setLevel(logging_levels.get(args.log_level))
if args.version:
print_version()
quit()
@@ -124,6 +104,14 @@ if __name__ == "__main__":
print_banner()
# Setting session
session_input = Input_Data(
profile=args.profile,
role_arn=args.role,
session_duration=args.session_duration,
external_id=args.external_id,
regions=args.filter_region,
)
provider_set_session(session_input)
# Load checks to execute