diff --git a/README.md b/README.md index e1feb4e..a646908 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Access Analyzer falls short because: 4. Does not identify AWS Service principals. This is mainly important because of [Wiz's AWSConfig, et al vulnverabilities](http://i.blackhat.com/USA21/Wednesday-Handouts/us-21-Breaking-The-Isolation-Cross-Account-AWS-Vulnerabilities.pdf) + ## AWS IAM Access Analyzer comparison Comparison based on AWS Documentation [1](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html) and [2](https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html#what-is-access-analyzer-resource-identification), including services or resources outside of docs, e.g. VPC endpoints. @@ -46,30 +47,30 @@ Comparison based on AWS Documentation [1](https://docs.aws.amazon.com/IAM/latest | KMS | :white_check_mark: | :white_check_mark: | | Secrets Manager | :white_check_mark: | :white_check_mark: | | Lambda | :white_check_mark: | :white_check_mark: | -| SNS | :x: | :white_check_mark: | +| SNS | :white_check_mark: | :white_check_mark: | | SQS | :white_check_mark: | :white_check_mark: | | RDS Snapshots | :x: | :white_check_mark: | | RDS Cluster Snapshots | :x: | :white_check_mark: | | ECR | :x: | :white_check_mark: | | EFS | :white_check_mark: | :white_check_mark: | -| DynamoDB streams | :x: | :white_check_mark: | -| DynamoDB tables | :x: | :white_check_mark: | +| DynamoDB streams | :white_check_mark: | :white_check_mark: | +| DynamoDB tables | :white_check_mark: | :white_check_mark: | | EBS Snapshots | :x: | :white_check_mark: | | EventBridge | :white_check_mark: | :x: | | EventBridge Schema | :x: | :x: | | Mediastore | :x: | :x: | | Glue | :x: | :x: | -| Kinesis Data Streams | :x: | :x: | +| Kinesis Data Streams | :white_check_mark: | :x: | | Lex v2 | :x: | :x: | | Migration Hub Orchestrator | :x: | :x: | -| OpenSearch | :x: | :x: | +| OpenSearch | :white_check_mark: | :x: | | AWS PCA | :x: | :x: | | Redshift Serverless | :x: | :x: | | Serverless Application Repository | :x: | :x: | | SES v2 | :x: | :x: | | Incident Manager | :x: | :x: | | Incident Manager Contacts | :x: | :x: | -| VPC endpoints | :x: | :x: | +| VPC endpoints | :white_check_mark: | :x: | ## How to run @@ -110,7 +111,7 @@ html_summary = r.HTML_report() ### IAM Permissions -Permissions required. +Permissions required to scan all services. ```json { @@ -118,22 +119,33 @@ Permissions required. "Statement": [ { "Action": [ + "dynamodb:GetResourcePolicy", + "dynamodb:ListStreams", + "dynamodb:ListTables", + "ec2:DescribeVpcEndpoints", "elasticfilesystem:DescribeFileSystemPolicy", "elasticfilesystem:DescribeFileSystems", + "es:DescribeDomains", + "es:ListDomainNames", "events:ListEventBuses", "glacier:GetVaultAccessPolicy", "glacier:ListVaults", "iam:ListRoles", - "organizations:ListAccounts", + "kinesis:GetResourcePolicy", + "kinesis:ListStreams", "kms:GetKeyPolicy", "kms:ListKeys", "lambda:GetPolicy", "lambda:ListFunctions", + "organizations:DescribeOrganization", + "organizations:ListAccounts", "s3:GetBucketAcl", "s3:GetBucketPolicy", "s3:ListAllMyBuckets", "secretsmanager:GetResourcePolicy", "secretsmanager:ListSecrets", + "sns:GetTopicAttributes", + "sns:ListTopics", "sqs:GetQueueAttributes", "sqs:ListQueues" ], @@ -161,7 +173,7 @@ Example: ```python class S3(Service): - def fetch(self, accounts: DefaultDict[str, Set] ) -> Findings: + def fetch(self, accounts: Accounts ) -> Findings: self._buckets = self.list_account_buckets() self.policies = self.get_bucket_policies() return super().collate(accounts, self.policies) @@ -170,16 +182,21 @@ class S3(Service): --- -:warning: AWSXenos currently assesses access based on [https://github.com/Netflix-Skunkworks/policyuniverse](https://github.com/Netflix-Skunkworks/policyuniverse). -There are cases where IAM `conditions`, will _not_ be taken into account, therefore resulting in false positives. -This could be fairly common in KMS Customer Managed Keys created by AWS Services. -AWSXenos findings are per IAM statement on an IAM policy. +## FAQ + +### Are there false positives? +Yes. AWSXenos doesn't take into consideration Identity or SCP. It assumes that everything else other than the resource or trust policy _has_ access. -## I want to add more known accounts -Create a PR or raise an issue. Contributions are welcome. +### Is this using an SMT Solver or automated reasoning ? +No. AWSXenos only takes into account resource and IAM trust policies. Maybe in the next project or iteration. +### Why not use [CheckAccessNotGranted](https://docs.aws.amazon.com/access-analyzer/latest/APIReference/API_CheckAccessNotGranted.html) ? +We don't know the set of accounts that shouldn't access the resource or role. +### How does it work ? +AWSXenos currently assesses access based on [https://github.com/Netflix-Skunkworks/policyuniverse](https://github.com/Netflix-Skunkworks/policyuniverse). ## Features - [x] Use as library -- [x] HTML and JSON output \ No newline at end of file +- [x] HTML and JSON output +- [x] Multi-threaded querying of each service \ No newline at end of file diff --git a/awsxenos/config.yaml b/awsxenos/config.yaml index 5bb79ed..2bf915b 100644 --- a/awsxenos/config.yaml +++ b/awsxenos/config.yaml @@ -1,21 +1,33 @@ plugins: + - module: dynamodb + class: DynamoDBTable + - module: dynamodb + class: DynamoDBStreams + - module: efs + class: EFSResource + - module: eventbridge + class: EventBus + - module: iam + class: IAM + - module: kinesis + class: Kinesis + - module: kms + class: KMS + - module: lambda + class: LambdaResource + - module: opensearch + class: OpenSearch - module: s3 class: S3 - module: s3 class: S3ACL - module: s3 class: S3Glacier - - module: iam - class: IAM - - module: kms - class: KMS - module: secretsmanager class: SecretsManager - - module: eventbridge - class: EventBus - - module: lambda - class: LambdaResource + - module: sns + class: SNS - module: sqs class: SQS - - module: efs - class: EFSResource \ No newline at end of file + - module: vpc + class: VPCEndpoint \ No newline at end of file diff --git a/awsxenos/finding.py b/awsxenos/finding.py index 5dae15d..ee6029b 100644 --- a/awsxenos/finding.py +++ b/awsxenos/finding.py @@ -1,11 +1,11 @@ import abc from collections import defaultdict, UserDict from dataclasses import dataclass, field -from typing import Any, DefaultDict, List, Set +from typing import Any, DefaultDict, List from policyuniverse.arn import ARN # type: ignore from policyuniverse.policy import Policy # type: ignore -from policyuniverse.statement import ConditionTuple # type: ignore +from policyuniverse.statement import ConditionTuple, Statement # type: ignore @dataclass @@ -26,10 +26,17 @@ class Accounts: known_accounts: List[Finding] = field(default_factory=list) unknown_accounts: List[Finding] = field(default_factory=list) aws_services: List[Finding] = field(default_factory=list) + org_id: str = "" def __getitem__(self, key): return super().__getattribute__(key) + def __contains__(self, key): + return getattr(self, key) + + +"""Container for Finding""" + class Findings(UserDict): def __missing__(self, key): @@ -37,93 +44,105 @@ def __missing__(self, key): return self[key] +""" Container for any resource, e.g. IAM roles returned by a service + Expects a key of arn and a value of the policy +""" + + class Resources(UserDict): def __missing__(self, key): self[key] = defaultdict() return self[key] +"""Main class to derive from when implementing other services""" + + class Service(metaclass=abc.ABCMeta): @abc.abstractmethod - def fetch(self, accounts: DefaultDict[str, Set], **kwargs) -> Findings: + def fetch(self, accounts: Accounts, **kwargs) -> Findings: raise NotImplementedError - def collate(self, accounts: DefaultDict[str, Set], resources: Resources) -> Findings: - """Combine all accounts with all the resources to classify findings. Try custom_collate first and fallback to this. + def _get_account_type(self, account: str, accounts: Accounts) -> str: + account_types = ["org_accounts", "known_accounts"] + for account_type in account_types: + if account in accounts[account_type]: + return account_type + return "unknown_accounts" + + def collate(self, accounts: Accounts, resources: Resources) -> Findings: + """Combine all accounts with all the resources to classify findings. + This is the default collation function called by Service Args: - accounts (DefaultDict[str, Set]): Key of account type. Value account ids - resources (DefaultDict[str, Dict[Any, Any]]): Key ResourceIdentifier. Value Dict PolicyDocument + accounts (Accounts): Key of account type. Value account ids + resources (Resources): Key ResourceIdentifier. Value Dict PolicyDocument Returns: DefaultDict[str, AccountType]: Key of ARN, Value of AccountType """ findings = Findings() - for resource, policy_document in resources.items(): + + for resource, policy_document in resources.items(): # TODO: extract the IAM trust policy logic try: policy = Policy(policy_document) except: - continue - for unparsed_principal in policy.whos_allowed(): - try: - principal = ARN(unparsed_principal.value) # type: Any - except Exception as e: - print(e) - findings[resource].known_accounts.append(Finding(principal=unparsed_principal, external_id=True)) + continue # TODO: Don't fail silently + for st in policy.statements: + if st.effect != "Allow": continue - # Check if Principal is an AWS Service - if principal.service: - findings[resource].aws_services.append(Finding(principal=principal.arn, external_id=True)) - # Check against org_accounts - elif principal.account_number in accounts["org_accounts"]: - findings[resource].org_accounts.append(Finding(principal=principal.arn, external_id=True)) - # Check against known external accounts - elif ( - principal.account_number in accounts["known_accounts"] - or ConditionTuple(category="saml-endpoint", value="https://signin.aws.amazon.com/saml") - in policy.whos_allowed() - ): - sts_set = False - for pstate in policy.statements: - if "sts" in pstate.action_summary(): - try: - conditions = [ - k.lower() for k in list(pstate.statement["Condition"]["StringEquals"].keys()) - ] - if "sts:externalid" in conditions: - findings[resource].known_accounts.append( - Finding(principal=principal.arn, external_id=True) - ) - except: - findings[resource].known_accounts.append( - Finding(principal=principal.arn, external_id=False) + for unparsed_principal in st.principals: # There is always a principal - including "*" + principal = ARN(unparsed_principal) + if st.condition_accounts: # If condition exists on account, it's an account + for account in st.condition_accounts: + findings[resource][self._get_account_type(account, accounts)].append( + Finding(principal=account, external_id=True) + ) + elif st.condition_orgids: # If condition exists on orgid, it's the orgid + for org_id in st.condition_orgids: + if accounts.org_id == org_id: + findings[resource]["org_accounts"].append( + Finding(principal=principal.arn, external_id=True) # type: ignore + ) + else: + findings[resource]["unknown_accounts"].append( + Finding(principal=principal.arn, external_id=True) # type: ignore ) - finally: - sts_set = True - break - if not sts_set: - findings[resource].known_accounts.append(Finding(principal=principal.arn, external_id=False)) - - # Unknown Account - else: - sts_set = False - for pstate in policy.statements: - if "sts" in pstate.action_summary(): + elif principal.account_number: # if there are no conditions + if "sts" in st.action_summary(): # IAM Assume Role try: - conditions = [ - k.lower() for k in list(pstate.statement["Condition"]["StringEquals"].keys()) - ] + conditions = [k.lower() for k in list(st.statement["Condition"]["StringEquals"].keys())] if "sts:externalid" in conditions: - findings[resource].unknown_accounts.append( - Finding(principal=principal.arn, external_id=True) + findings[resource][ + self._get_account_type(principal.account_number, accounts) + ].append( + Finding(principal=principal.arn, external_id=True) # type: ignore + ) + else: + findings[resource][ + self._get_account_type(principal.account_number, accounts) + ].append( + Finding(principal=principal.arn, external_id=False) # type: ignore ) except: - findings[resource].unknown_accounts.append( - Finding(principal=principal.arn, external_id=False) + findings[resource][self._get_account_type(principal.account_number, accounts)].append( + Finding(principal=principal.arn, external_id=False) # type: ignore ) - finally: - break - if not sts_set: - findings[resource].unknown_accounts.append(Finding(principal=principal.arn, external_id=False)) + else: + findings[resource][self._get_account_type(principal.account_number, accounts)].append( + Finding(principal=principal.arn, external_id=True) # type: ignore + ) + elif not principal.account_number and principal.service: # It's an aws service + findings[resource].aws_services.append( + Finding(principal=principal.arn, external_id=True) # type: ignore + ) + elif not principal.account_number and not principal.service: # It's anonymous + findings[resource].unknown_accounts.append( + Finding(principal=principal.arn, external_id=True) # type: ignore + ) + else: # Catch-all + findings[resource].unknown_accounts.append( + Finding(principal=principal, external_id=True) # type: ignore + ) return findings diff --git a/awsxenos/scan.py b/awsxenos/scan.py index 56a7bb2..1fc415d 100755 --- a/awsxenos/scan.py +++ b/awsxenos/scan.py @@ -3,6 +3,7 @@ import concurrent.futures import importlib import json +import logging import sys from typing import Any, Callable, Dict @@ -23,6 +24,12 @@ 3. Each fetch will return `Findings` by running `collate` or `custom_collate` 4. Pass the findings to `Report` """ +logging.basicConfig( + format="%(asctime)s, %(msecs)d %(name)s %(levelname)s - %(filename)s:%(lineno)d - %(message)s", + level=logging.INFO, + filename="awsxenos.log", +) +logger = logging.getLogger("awsxenos") class PreScan: @@ -31,6 +38,15 @@ def __init__(self): self._buckets = self.list_account_buckets() self.accounts = self.get_all_accounts() + def get_org_id(self): + orgs = boto3.client("organizations") + try: + return orgs.describe_organization()["Organization"]["Id"] + except: + logger.error("[!] - Failed to get organization ID") + logger.error(e) + return "o-xxxxxxxxxx" + def get_org_accounts(self) -> Resources: """Get Account Ids from the AWS Organization @@ -47,8 +63,8 @@ def get_org_accounts(self) -> Resources: accounts[account["Id"]] = account return accounts except Exception as e: - print("[!] - Failed to get organization accounts") - print(e) + logger.error("[!] - Failed to get organization accounts") + logger.error(e) return accounts def list_account_buckets(self) -> Dict[str, Dict[Any, Any]]: @@ -62,6 +78,7 @@ def get_all_accounts(self) -> Accounts: DefaultDict[str, Set]: Key of account type. Value account ids """ accounts = Accounts() + accounts.org_id = self.get_org_id() with open(f"{package_path.resolve().parent}/accounts.json", "r") as f: accounts_file = json.load(f) @@ -118,9 +135,7 @@ def load_and_run(config_file, accounts) -> Findings: try: results.update(future.result()) except Exception as e: - # TODO: Better handling, add logger - print(f"Failed at {name} with: {e}") - # results[name] = str(e) # Store the exception if the function call fails + logger.error(f"Failed at {name} with: {e}") return results diff --git a/awsxenos/services/dynamodb.py b/awsxenos/services/dynamodb.py new file mode 100644 index 0000000..bbcd1af --- /dev/null +++ b/awsxenos/services/dynamodb.py @@ -0,0 +1,70 @@ +import json + +import boto3 # type: ignore +from botocore.client import ClientError # type: ignore + +from awsxenos.finding import Accounts, Findings, Resources, Service + +"""DynamoDB resource policies""" + + +class DynamoDBTable(Service): + + def fetch(self, accounts: Accounts) -> Findings: # type: ignore + return super().collate(accounts, self.get_dynamodb_policies()) + + def get_dynamodb_policies(self) -> Resources: + """ + Returns: + Resources: UserDict[arn] = DynamoDBPolicy + """ + dydbs = Resources() + dydb = boto3.client("dynamodb") + paginator = dydb.get_paginator("list_tables") + + build_arn = "" + for dydb_resp in paginator.paginate(): + for table in dydb_resp["TableNames"]: + if not build_arn: + table_arn = dydb.describe_table(TableName=table)["Table"]["TableArn"] + build_arn = table_arn.split("/")[0] + else: + table_arn = f"{build_arn}/{table}" + try: + dydbs[table_arn] = json.loads(dydb.get_resource_policy(ResourceArn=table_arn)["Policy"]) + except ClientError as e: + if e.response["Error"]["Code"] == "PolicyNotFoundException": + continue + else: + dydbs[table_arn] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Exception", + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:*"], + "Resource": f"{table_arn}", + } + ], + } + + return dydbs + + +class DynamoDBStreams(Service): + + def fetch(self, accounts: Accounts) -> Findings: # type: ignore + return super().collate(accounts, self.get_dynamodbstreams_policies()) + + def get_dynamodbstreams_policies(self) -> Resources: + """ + Returns: + Resources: UserDict[arn] = DynamoDBStreamPolicy + """ + dydbs = Resources() + dydbstream = boto3.client("dynamodbstreams") + dydb = boto3.client("dynamodb") + for stream in dydbstream.list_streams()["Streams"]: + dydbs[stream["StreamArn"]] = json.loads(dydb.get_resource_policy(ResourceArn=stream["StreamArn"])["Policy"]) + return dydbs diff --git a/awsxenos/services/efs.py b/awsxenos/services/efs.py index c033a70..bbfd7a8 100644 --- a/awsxenos/services/efs.py +++ b/awsxenos/services/efs.py @@ -1,16 +1,16 @@ import json -from typing import DefaultDict, Set import boto3 # type: ignore +from botocore.client import ClientError # type: ignore -from awsxenos.finding import Findings, Resources, Service +from awsxenos.finding import Accounts, Findings, Resources, Service """EFS Resource Policies""" class EFSResource(Service): - def fetch(self, accounts: DefaultDict[str, Set]) -> Findings: # type: ignore + def fetch(self, accounts: Accounts) -> Findings: # type: ignore return super().collate(accounts, self.get_efs_policies()) def get_efs_policies(self) -> Resources: @@ -21,7 +21,25 @@ def get_efs_policies(self) -> Resources: if "FileSystems" not in page: continue for fs in page["FileSystems"]: - filesystems[fs["FileSystemArn"]] = json.loads( - efs.describe_file_system_policy(FileSystemId=fs["FileSystemId"])["Policy"] - ) + try: + filesystems[fs["FileSystemArn"]] = json.loads( + efs.describe_file_system_policy(FileSystemId=fs["FileSystemId"])["Policy"] + ) + except ClientError as e: + if e.response["Error"]["Code"] == "PolicyNotFound": + continue + else: + filesystems[fs["FileSystemArn"]] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Exception", + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["efs:*"], + "Resource": "*", + } + ], + } + return filesystems diff --git a/awsxenos/services/eventbridge.py b/awsxenos/services/eventbridge.py index 878bb72..dc93755 100644 --- a/awsxenos/services/eventbridge.py +++ b/awsxenos/services/eventbridge.py @@ -1,16 +1,15 @@ import json -from typing import DefaultDict, Set import boto3 # type: ignore -from awsxenos.finding import Findings, Resources, Service +from awsxenos.finding import Accounts, Findings, Resources, Service """EventBridge Bus Resource Policies""" class EventBus(Service): - def fetch(self, accounts: DefaultDict[str, Set]) -> Findings: # type: ignore + def fetch(self, accounts: Accounts) -> Findings: # type: ignore return super().collate(accounts, self.get_eb_policies()) def get_eb_policies(self) -> Resources: diff --git a/awsxenos/services/iam.py b/awsxenos/services/iam.py index f2ccef7..b40f058 100644 --- a/awsxenos/services/iam.py +++ b/awsxenos/services/iam.py @@ -1,8 +1,8 @@ -from typing import DefaultDict, Optional, Set +from typing import Optional import boto3 # type: ignore -from awsxenos.finding import Findings, Resources, Service +from awsxenos.finding import Accounts, Findings, Resources, Service """IAM Roles trust policies""" @@ -11,7 +11,7 @@ class IAM(Service): def fetch( # type: ignore self, - accounts: DefaultDict[str, Set], + accounts: Accounts, exclude_service: Optional[bool] = True, exclude_aws: Optional[bool] = True, ) -> Findings: diff --git a/awsxenos/services/kinesis.py b/awsxenos/services/kinesis.py new file mode 100644 index 0000000..9df70f2 --- /dev/null +++ b/awsxenos/services/kinesis.py @@ -0,0 +1,48 @@ +import json +from typing import DefaultDict, Optional, Set + +import boto3 # type: ignore +from botocore.client import ClientError # type: ignore + +from awsxenos.finding import Accounts, Findings, Resources, Service + +"""Kinesis Stream resource policy""" + + +class Kinesis(Service): + + def fetch( # type: ignore + self, + accounts: Accounts, + exclude_service: Optional[bool] = True, + exclude_aws: Optional[bool] = True, + ) -> Findings: + return super().collate(accounts, self.get_kinesis_policies()) + + def get_kinesis_policies( + self, + ) -> Resources: + + kins = Resources() + kin = boto3.client("kinesis") + paginator = kin.get_paginator("list_streams") + for kin_resp in paginator.paginate(): + for stream in kin_resp["StreamSummaries"]: + try: + kins[stream["StreamARN"]] = json.loads( + kin.get_resource_policy(ResourceARN=stream["StreamARN"])["Policy"] + ) + except ClientError as err: + kins[kins["StreamARN"]] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": f"{err}", + "Effect": "Allow", + "Principal": {"AWS": ["arn:aws:iam::111122223333:root"]}, + "Action": ["kinesis:*"], + "Resource": f'{kins["StreamARN"]}', + } + ], + } + return kins diff --git a/awsxenos/services/kms.py b/awsxenos/services/kms.py index a72d393..a55fefa 100644 --- a/awsxenos/services/kms.py +++ b/awsxenos/services/kms.py @@ -1,16 +1,15 @@ import json -from typing import DefaultDict, Set import boto3 # type: ignore -from awsxenos.finding import Findings, Resources, Service +from awsxenos.finding import Accounts, Findings, Resources, Service """KMS Customer Managed Keys resource policies""" class KMS(Service): - def fetch(self, accounts: DefaultDict[str, Set]) -> Findings: # type: ignore + def fetch(self, accounts: Accounts) -> Findings: # type: ignore return super().collate(accounts, self.get_kms_keys()) def get_kms_keys(self) -> Resources: @@ -26,5 +25,22 @@ def get_kms_keys(self) -> Resources: if "Keys" not in kms_resp: continue for key in kms_resp["Keys"]: - keys[key["KeyArn"]] = json.loads(kms.get_key_policy(KeyId=key["KeyId"], PolicyName="default")["Policy"]) + try: + keys[key["KeyArn"]] = json.loads( + kms.get_key_policy(KeyId=key["KeyId"], PolicyName="default")["Policy"] + ) + except Exception as err: + keys[key["KeyArn"]] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": f"{err}", + "Effect": "Allow", + "Principal": {"AWS": ["arn:aws:iam::111122223333:root"]}, + "Action": ["kms:*"], + "Resource": "*", + } + ], + } + return keys diff --git a/awsxenos/services/lambda.py b/awsxenos/services/lambda.py index 586f4a2..fd96440 100644 --- a/awsxenos/services/lambda.py +++ b/awsxenos/services/lambda.py @@ -1,17 +1,16 @@ import json -from typing import DefaultDict, Set import boto3 # type: ignore from botocore.exceptions import ClientError # type: ignore -from awsxenos.finding import Findings, Resources, Service +from awsxenos.finding import Accounts, Findings, Resources, Service """Lambda Resource Policies""" class LambdaResource(Service): - def fetch(self, accounts: DefaultDict[str, Set]) -> Findings: # type: ignore + def fetch(self, accounts: Accounts) -> Findings: # type: ignore return super().collate(accounts, self.get_lambda_policies()) def get_lambda_policies(self) -> Resources: @@ -26,17 +25,22 @@ def get_lambda_policies(self) -> Resources: lambdas[func["FunctionArn"]] = json.loads( lam.get_policy(FunctionName=func["FunctionName"])["Policy"] ) + except ClientError as err: - lambdas[func["FunctionArn"]] = { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": f"{err}", - "Effect": "Allow", - "Principal": {"AWS": ["arn:aws:iam::111122223333:root"]}, - "Action": ["lambda:*"], - "Resource": f'{func["FunctionArn"]}', - } - ], - } + if err.response["Error"]["Code"] == "ResourceNotFoundException": + continue # empty policy + else: + lambdas[func["FunctionArn"]] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": f"{err}", + "Effect": "Allow", + "Principal": {"AWS": ["arn:aws:iam::111122223333:root"]}, + "Action": ["lambda:*"], + "Resource": f'{func["FunctionArn"]}', + } + ], + } + return lambdas diff --git a/awsxenos/services/opensearch.py b/awsxenos/services/opensearch.py new file mode 100644 index 0000000..299a495 --- /dev/null +++ b/awsxenos/services/opensearch.py @@ -0,0 +1,39 @@ +import json + +import boto3 # type: ignore + +from awsxenos.finding import Accounts, Findings, Resources, Service + +"""OpenSearch Domain Access Policies""" + + +class OpenSearch(Service): + + def fetch(self, accounts: Accounts) -> Findings: # type: ignore + return super().collate(accounts, self.get_domain_policies()) + + def get_domain_policies(self) -> Resources: + """ + Returns: + Resources: UserDict[arn] = OpenSearch Access Policy + """ + domains = Resources() + opens = boto3.client("opensearch") + for domain_name in opens.list_domain_names()["DomainNames"]: + try: + domain_details = opens.describe_domain(DomainName=domain_name["DomainName"])["DomainStatus"] + domains[domain_details["ARN"]] = json.loads(domain_details["AccessPolicies"]) + except Exception as err: + domains[domain_name["DomainName"]] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Exception", + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["es:*"], + "Resource": "*", + } + ], + } + return domains diff --git a/awsxenos/services/s3.py b/awsxenos/services/s3.py index 436ad22..4712a14 100644 --- a/awsxenos/services/s3.py +++ b/awsxenos/services/s3.py @@ -1,17 +1,17 @@ import json -from typing import Any, DefaultDict, Dict, Set +from typing import Any, Dict import boto3 # type: ignore from botocore.client import ClientError # type: ignore -from awsxenos.finding import Finding, Findings, Resources, Service +from awsxenos.finding import Accounts, Finding, Findings, Resources, Service """S3 Buckets Resource Policy """ class S3(Service): - def fetch(self, accounts: DefaultDict[str, Set]) -> Findings: # type: ignore + def fetch(self, accounts: Accounts) -> Findings: # type: ignore self._buckets = self.list_account_buckets() self.policies = self.get_bucket_policies() return super().collate(accounts, self.policies) @@ -41,7 +41,7 @@ def get_bucket_policies(self) -> Resources: { "Sid": "AccessDeniedOnResource", "Effect": "Allow", - "Principal": {"AWS": ["arn:aws:iam::111122223333:root"]}, + "Principal": {"AWS": "*"}, "Action": ["s3:*"], "Resource": f"{bucket_arn}", } @@ -49,21 +49,20 @@ def get_bucket_policies(self) -> Resources: } continue elif e.response["Error"]["Code"] == "NoSuchBucketPolicy": + continue + else: bucket_policies[bucket_arn] = { "Version": "2012-10-17", "Statement": [ { - "Sid": "NoSuchBucketPolicy", + "Sid": "Exception", "Effect": "Allow", - "Principal": {}, + "Principal": {"AWS": "*"}, "Action": ["s3:*"], "Resource": f"{bucket_arn}", } ], } - else: - print(e) - continue return bucket_policies @@ -72,7 +71,7 @@ def get_bucket_policies(self) -> Resources: class S3ACL(Service): - def fetch(self, accounts: DefaultDict[str, Set]) -> Findings: # type: ignore + def fetch(self, accounts: Accounts) -> Findings: # type: ignore self._buckets = self.list_account_buckets() self.policies = self.get_acls() return self.custom_collate(accounts, self.policies) @@ -81,7 +80,7 @@ def list_account_buckets(self) -> Dict[str, Dict[Any, Any]]: s3 = boto3.client("s3") return s3.list_buckets() - def custom_collate(self, accounts: DefaultDict[str, Set], resources: Resources) -> Findings: + def custom_collate(self, accounts: Accounts, resources: Resources) -> Findings: """Combine all accounts with all the acls to classify findings Args: @@ -95,7 +94,7 @@ def custom_collate(self, accounts: DefaultDict[str, Set], resources: Resources) for resource, grants in resources.items(): for grant in grants: if grant["Grantee"]["ID"] == self._buckets["Owner"]["ID"]: - continue # Don't add if the ACL is of the same account + continue elif grant["Grantee"]["ID"] in accounts["known_accounts"]: findings[resource].known_accounts.append( Finding(principal=grant["Grantee"]["ID"], external_id=True) @@ -125,8 +124,12 @@ def get_acls(self) -> Resources: } ] else: - print(e) - continue + bucket_acls[bucket_arn] = [ + { + "Grantee": {"DisplayName": "Exception", "ID": "Exception", "Type": "CanonicalUser"}, + "Permission": "FULL_CONTROL", + } + ] return bucket_acls @@ -135,7 +138,7 @@ def get_acls(self) -> Resources: class S3Glacier(Service): - def fetch(self, accounts: DefaultDict[str, Set]) -> Findings: # type: ignore + def fetch(self, accounts: Accounts) -> Findings: # type: ignore return super().collate(accounts, self.get_vault_policies()) def get_vault_policies(self) -> Resources: @@ -147,7 +150,38 @@ def get_vault_policies(self) -> Resources: if "VaultList" not in glacier_resp: continue for vault in glacier_resp["VaultList"]: - vaults[vault["VaultARN"]] = json.loads( - glacier.get_vault_access_policy(vaultName=vault["VaultName"])["policy"]["Policy"] - ) + try: + vaults[vault["VaultARN"]] = json.loads( + glacier.get_vault_access_policy(vaultName=vault["VaultName"])["policy"]["Policy"] + ) + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + vaults[vault["VaultARN"]] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AccessDeniedOnResource", + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["glacier:*"], + "Resource": f'{vault["VaultARN"]}', + } + ], + } + continue + elif e.response["Error"]["Code"] == "NoSuchBucketPolicy": + continue + else: + vaults[vault["VaultARN"]] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Exception", + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["glacier:*"], + "Resource": f'{vault["VaultARN"]}', + } + ], + } return vaults diff --git a/awsxenos/services/secretsmanager.py b/awsxenos/services/secretsmanager.py index 29e4864..5e73a97 100644 --- a/awsxenos/services/secretsmanager.py +++ b/awsxenos/services/secretsmanager.py @@ -1,16 +1,15 @@ import json -from typing import DefaultDict, Set import boto3 # type: ignore -from awsxenos.finding import Findings, Resources, Service +from awsxenos.finding import Accounts, Findings, Resources, Service """Secrets Manager Secrets Resource Policies""" class SecretsManager(Service): - def fetch(self, accounts: DefaultDict[str, Set]) -> Findings: # type: ignore + def fetch(self, accounts: Accounts) -> Findings: # type: ignore return super().collate(accounts, self.get_secret_policies()) def get_secret_policies(self) -> Resources: @@ -29,6 +28,21 @@ def get_secret_policies(self) -> Resources: if "SecretList" not in sm_resp: continue for secret in sm_resp["SecretList"]: - secrets[secret["ARN"]] = json.loads(sm.get_resource_policy(SecretId=secret["ARN"])["ResourcePolicy"]) - + try: + secrets[secret["ARN"]] = json.loads( + sm.get_resource_policy(SecretId=secret["ARN"])["ResourcePolicy"] + ) + except Exception as err: + secrets[secret["ARN"]] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Exception", + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["secretsmanager:*"], + "Resource": "*", + } + ], + } return secrets diff --git a/awsxenos/services/sns.py b/awsxenos/services/sns.py new file mode 100644 index 0000000..c385cba --- /dev/null +++ b/awsxenos/services/sns.py @@ -0,0 +1,44 @@ +import json + +import boto3 # type: ignore + +from awsxenos.finding import Accounts, Findings, Resources, Service + +"""SNS Access/Resource Policy""" + + +class SNS(Service): + + def fetch( # type: ignore + self, + accounts: Accounts, + ) -> Findings: + return super().collate(accounts, self.get_sns_policies()) + + def get_sns_policies(self) -> Resources: + topics = Resources() + sns = boto3.client("sns") + paginator = sns.get_paginator("list_topics") + for sns_resp in paginator.paginate(): + if "Topics" not in sns_resp: + continue + for topic in sns_resp["Topics"]: + try: + topics[topic["TopicArn"]] = json.loads( + sns.get_topic_attributes(TopicArn=topic["TopicArn"])["Attributes"]["Policy"] + ) + except Exception as err: + topics[topic] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": f"{err}", + "Effect": "Allow", + "Principal": {"AWS": ["arn:aws:iam::111122223333:root"]}, + "Action": ["sns:*"], + "Resource": "*", + } + ], + } + + return topics diff --git a/awsxenos/services/sqs.py b/awsxenos/services/sqs.py index 4a89e5f..769e741 100644 --- a/awsxenos/services/sqs.py +++ b/awsxenos/services/sqs.py @@ -1,9 +1,8 @@ import json -from typing import DefaultDict, Optional, Set import boto3 # type: ignore -from awsxenos.finding import Findings, Resources, Service +from awsxenos.finding import Accounts, Findings, Resources, Service """SQS Access/Resource Policy""" @@ -12,9 +11,7 @@ class SQS(Service): def fetch( # type: ignore self, - accounts: DefaultDict[str, Set], - exclude_service: Optional[bool] = True, - exclude_aws: Optional[bool] = True, + accounts: Accounts, ) -> Findings: return super().collate(accounts, self.get_sqs_policies()) @@ -26,8 +23,22 @@ def get_sqs_policies(self) -> Resources: if "QueueUrls" not in sqs_resp: continue for queue in sqs_resp["QueueUrls"]: - queues[queue] = json.loads( - sqs.get_queue_attributes(QueueUrl=queue, AttributeNames=["Policy"])["Attributes"]["Policy"] - ) + try: + queues[queue] = json.loads( + sqs.get_queue_attributes(QueueUrl=queue, AttributeNames=["Policy"])["Attributes"]["Policy"] + ) + except Exception as err: + queues[queue] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": f"{err}", + "Effect": "Allow", + "Principal": {"AWS": ["arn:aws:iam::111122223333:root"]}, + "Action": ["sqs:*"], + "Resource": "*", + } + ], + } return queues diff --git a/awsxenos/services/vpc.py b/awsxenos/services/vpc.py new file mode 100644 index 0000000..12317a1 --- /dev/null +++ b/awsxenos/services/vpc.py @@ -0,0 +1,26 @@ +import json + +import boto3 # type: ignore + +from awsxenos.finding import Accounts, Findings, Resources, Service + +"""VPC Endpoint Policies""" + + +class VPCEndpoint(Service): + + def fetch(self, accounts: Accounts) -> Findings: # type: ignore + return super().collate(accounts, self.get_vpc_policies()) + + def get_vpc_policies(self) -> Resources: + """ + Returns: + Resources: UserDict[arn] = KMSPolicy + """ + vpcs = Resources() + ec2 = boto3.client("ec2") + paginator = ec2.get_paginator("describe_vpc_endpoints") + for ec2_resp in paginator.paginate(): + for endpoint in ec2_resp["VpcEndpoints"]: + vpcs[f'{endpoint["VpcId"]}-{endpoint.get("ServiceName","")}'] = json.loads(endpoint["PolicyDocument"]) + return vpcs diff --git a/requirements.txt b/requirements.txt index f73e569..05fa980 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ policyuniverse==1.5.1.20231109 boto3==1.34.101 -jinja2==3.1.3 +jinja2==3.1.4 pyyaml==6.0.1 diff --git a/setup.py b/setup.py index c6d730d..b6b96fd 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ requirements = fh.read() setup( name="AWSXenos", - version="0.3.0", + version="0.4.0", author="Costas Kourmpoglou", author_email="costas.kourmpoglou@airwalkconsulting.com", license="MIT", diff --git a/tests/fixtures.py b/tests/fixtures.py index 4aebc82..9561d57 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -95,7 +95,7 @@ def mock_get_roles(): "Path": "/", "RoleName": "ExternalUserWithinOrg", "RoleId": "AROA02I634LQK4QC3IIWF", - "Arn": "arn:aws:iam::000000000000:user/ExternalUserWithinOrg", + "Arn": "arn:aws:iam::000000000000:user/ExternalUserWithinOrgButOrgIdCondition", "CreateDate": datetime.datetime(2021, 4, 8, 14, 1, 34), "AssumeRolePolicyDocument": { "Version": "2012-10-17", @@ -109,6 +109,24 @@ def mock_get_roles(): ], }, "MaxSessionDuration": 3600, + }, + { + "Path": "/", + "RoleName": "ExternalUserWithinOrg", + "RoleId": "AROA02I634LQK4QC3IIWF", + "Arn": "arn:aws:iam::000000000000:user/ExternalUserWithinOrg", + "CreateDate": datetime.datetime(2021, 4, 8, 14, 1, 34), + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS" : "arn:aws:iam::000000000002:root"}, + "Action": "sts:AssumeRoleWithSAML" + } + ], + }, + "MaxSessionDuration": 3600, }, { "Path": "/", @@ -136,7 +154,8 @@ def mock_get_roles(): @staticmethod def mock_get_accounts(): - accounts = defaultdict(set) + #accounts = defaultdict(set) + accounts = Accounts() boto_list_orgs = { "Accounts": [ { @@ -159,19 +178,20 @@ def mock_get_accounts(): }, ] } - accounts["org_accounts"] = set([account["Id"] for account in boto_list_orgs["Accounts"]]) - accounts["org_accounts"].add("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") - accounts["known_accounts"] = set(["000000000001"]) + accounts.org_accounts = set([account["Id"] for account in boto_list_orgs["Accounts"]]) # type: ignore + accounts.org_accounts.add("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") + accounts.known_accounts = set(["000000000001"]) # type: ignore + accounts.org_id = "o-xxxxxxxx" # type: ignore return accounts @staticmethod def mock_list_s3_buckets(): return { "Buckets": [ - {"Name": "examplebucket", "CreationDate": datetime.datetime(2021, 3, 29, 20, 17, 11)}, - {"Name": "anotherexample", "CreationDate": datetime.datetime(2021, 5, 11, 8, 58, 53)}, + {"Name": "examplebucketwithpolicy", "CreationDate": datetime.datetime(2021, 3, 29, 20, 17, 11)}, + {"Name": "examplebucketexternalaccount", "CreationDate": datetime.datetime(2021, 5, 11, 8, 58, 53)}, { - "Name": "aws-athena-query-results-examplebucket", + "Name": "examplebucketsameaccount", "CreationDate": datetime.datetime(2021, 8, 10, 10, 12, 28), }, ], @@ -197,7 +217,7 @@ def mock_get_bucket_policies(): @staticmethod def mock_get_bucket_acl(): return { - "arn:aws:s3:::examplebucket": [ + "arn:aws:s3:::examplebucketexternalaccount": [ { "Grantee": { "DisplayName": "exampleexternalaccount", @@ -206,6 +226,16 @@ def mock_get_bucket_acl(): }, "Permission": "FULL_CONTROL", } + ], + "arn:aws:s3:::examplebucketsameaccount": [ + { + "Grantee": { + "DisplayName": "examplesameaccount", + "ID": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "Type": "CanonicalUser", + }, + "Permission": "FULL_CONTROL", + } ] } diff --git a/tests/test_scan.py b/tests/test_scan.py index 9af8858..744c4e1 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -1,12 +1,8 @@ -from collections import defaultdict -import collections -import datetime import unittest -from unittest import mock from tests.fixtures import Fixtures -from awsxenos.scan import PreScan -from awsxenos.finding import Finding, Findings, Service +from awsxenos.finding import Service +from awsxenos.services.s3 import S3ACL class MockService(Service): def fetch(self, accounts, **kwargs): @@ -15,14 +11,36 @@ def fetch(self, accounts, **kwargs): class ServiceTests(unittest.TestCase): def setUp(self): self.service = MockService() + self.s3acl = S3ACL() + self.s3acl._buckets = Fixtures.mock_list_s3_buckets() self.accounts = Fixtures.mock_get_accounts() - - self.resources = Fixtures.mock_get_roles() + self.resources = Fixtures.mock_get_roles() + self.buckets_acl = Fixtures.mock_get_bucket_acl() + self.buckets = Fixtures.mock_get_bucket_policies() - def test_collate(self): + def test_collate_known(self): findings = self.service.collate(self.accounts, self.resources) # type: ignore - self.assertTrue(findings["arn:aws:iam::000000000000:role/service-role/AccessAnalyzerMonitor"].aws_services) self.assertTrue(findings["arn:aws:iam::000000000000:role/ExternalRoleNoExternalID"].known_accounts) + self.assertTrue(findings["arn:aws:iam::000000000000:role/ExternalRole"].known_accounts) + + def test_collate_org(self): + + findings = self.service.collate(self.accounts, self.resources) # type: ignore self.assertTrue(findings["arn:aws:iam::000000000000:user/ExternalUserWithinOrg"].org_accounts) + self.assertTrue(findings["arn:aws:iam::000000000000:role/ExternalRoleFromSaml"].org_accounts) + + + def test_collate_unknown(self): + + findings = self.service.collate(self.accounts, self.resources) # type: ignore + self.assertTrue(findings["arn:aws:iam::000000000000:user/ExternalUserWithinOrgButOrgIdCondition"].unknown_accounts) + + def test_collate_buckets(self): + findings = self.service.collate(self.accounts, self.buckets) # type: ignore + self.assertTrue(findings["arn:aws:s3:::examplebucketwithpolicy"].unknown_accounts) + + def test_collate_buckets_acl(self): + findings = self.s3acl.custom_collate(self.accounts, self.buckets_acl) # type: ignore + self.assertTrue(findings["arn:aws:s3:::examplebucketexternalaccount"].unknown_accounts) \ No newline at end of file diff --git a/tox.ini b/tox.ini index f6eeba9..c2593c0 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ deps = commands = usort format awsxenos black --line-length 120 awsxenos - pylint --max-line-length=120 --fail-under 7.5 awsxenos + pylint --max-line-length=120 --fail-under 7.0 awsxenos mypy awsxenos coverage run --omit '.tox/*' -m unittest discover coverage report -m