Skip to content

Commit

Permalink
refactor collate, add services
Browse files Browse the repository at this point in the history
  • Loading branch information
costasko committed May 23, 2024
1 parent 1689308 commit f573d44
Show file tree
Hide file tree
Showing 19 changed files with 511 additions and 141 deletions.
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,24 +52,24 @@ Comparison based on AWS Documentation [1](https://docs.aws.amazon.com/IAM/latest
| 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

Expand Down Expand Up @@ -110,21 +110,30 @@ html_summary = r.HTML_report()

### IAM Permissions

Permissions required.
Permissions required to scan all services.

```json
{
"Version": "2012-10-17",
"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:DescribeOrganization",
"organizations:ListAccounts",
"kinesis:GetResourcePolicy",
"kinesis:ListStreams",
"kms:GetKeyPolicy",
"kms:ListKeys",
"lambda:GetPolicy",
Expand Down Expand Up @@ -161,7 +170,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)
Expand Down
10 changes: 9 additions & 1 deletion awsxenos/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,12 @@ plugins:
- module: sqs
class: SQS
- module: efs
class: EFSResource
class: EFSResource
- module: kinesis
class: Kinesis
- module: dynamodb
class: DynamoDBTable
- module: dynamodb
class: DynamoDBStreams
- module: vpc
class: VPCEndpoint
145 changes: 82 additions & 63 deletions awsxenos/finding.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,104 +26,123 @@ 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):
self[key] = Accounts()
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
10 changes: 10 additions & 0 deletions awsxenos/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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:
print("[!] - Failed to get organization ID")
print(e)
return "o-xxxxxxxxxx"

def get_org_accounts(self) -> Resources:
"""Get Account Ids from the AWS Organization
Expand Down Expand Up @@ -62,6 +71,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)
Expand Down
70 changes: 70 additions & 0 deletions awsxenos/services/dynamodb.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit f573d44

Please sign in to comment.