diff --git a/plugins/aws/resoto_plugin_aws/aws_client.py b/plugins/aws/resoto_plugin_aws/aws_client.py index 423533b50..90b5aafc8 100644 --- a/plugins/aws/resoto_plugin_aws/aws_client.py +++ b/plugins/aws/resoto_plugin_aws/aws_client.py @@ -120,6 +120,8 @@ def __to_json(self, node: Any, **kwargs: Any) -> JsonElement: return {key: self.__to_json(value, **kwargs) for key, value in node.items()} elif isinstance(node, datetime): return utc_str(node) + elif isinstance(node, bytes): + return node.decode("utf-8") else: raise AttributeError(f"Unsupported type: {type(node)}") @@ -223,7 +225,7 @@ def list( self, aws_service: str, action: str, - result_name: Optional[str], + result_name: Optional[str] = None, expected_errors: Optional[List[str]] = None, **kwargs: Any, ) -> List[Any]: @@ -239,7 +241,7 @@ def get( self, aws_service: str, action: str, - result_name: Optional[str], + result_name: Optional[str] = None, expected_errors: Optional[List[str]] = None, **kwargs: Any, ) -> Optional[Json]: diff --git a/plugins/aws/resoto_plugin_aws/collector.py b/plugins/aws/resoto_plugin_aws/collector.py index 9debc0263..30a7a4acc 100644 --- a/plugins/aws/resoto_plugin_aws/collector.py +++ b/plugins/aws/resoto_plugin_aws/collector.py @@ -17,6 +17,7 @@ dynamodb, ec2, ecs, + efs, eks, elasticbeanstalk, elasticache, @@ -63,6 +64,7 @@ + cognito.resources + dynamodb.resources + ec2.resources + + efs.resources + ecs.resources + eks.resources + elasticbeanstalk.resources @@ -231,6 +233,7 @@ def collect_resource(resource: Type[AwsResource], rb: GraphBuilder) -> None: regional_builder.core_feedback.error(msg, log) return None + # TODO: move into separate AwsAccountSettings def update_account(self) -> None: log.info(f"Collecting AWS IAM Account Summary in account {self.account.dname}") sm = self.client.get("iam", "get-account-summary", "SummaryMap") or {} diff --git a/plugins/aws/resoto_plugin_aws/resource/efs.py b/plugins/aws/resoto_plugin_aws/resource/efs.py new file mode 100644 index 000000000..ca500e2b6 --- /dev/null +++ b/plugins/aws/resoto_plugin_aws/resource/efs.py @@ -0,0 +1,187 @@ +from typing import Optional, ClassVar, Dict, List, Type + +from attr import field, define + +from resoto_plugin_aws.aws_client import AwsClient +from resoto_plugin_aws.resource.base import AwsApiSpec, GraphBuilder, AwsResource +from resoto_plugin_aws.resource.kms import AwsKmsKey +from resoto_plugin_aws.utils import ToDict +from resotolib.baseresources import BaseVolume +from resotolib.json_bender import Bender, S, MapValue, F, Bend +from resotolib.types import Json + + +class EfsTaggable: + def update_resource_tag(self, client: AwsClient, key: str, value: str) -> bool: + client.call( + aws_service="efs", + action="tag-resource", + result_name=None, + resourceId=self.id, # type: ignore + tags={key: value}, + ) + return True + + def delete_resource_tag(self, client: AwsClient, key: str) -> bool: + client.call( + aws_service="efs", + action="untag-resource", + result_name=None, + resourceId=self.id, # type: ignore + tagKeys=[key], + ) + return True + + @classmethod + def called_mutator_apis(cls) -> List[AwsApiSpec]: + return [AwsApiSpec("efs", "tag-resource"), AwsApiSpec("efs", "untag-resource")] + + +@define(eq=False, slots=False) +class AwsEfsMountTarget(AwsResource): + kind: ClassVar[str] = "aws_efs_mount_target" + mapping: ClassVar[Dict[str, Bender]] = { + "id": S("MountTargetId"), + "owner_id": S("OwnerId"), + "life_cycle_state": S("LifeCycleState"), + "ip_address": S("IpAddress"), + "availability_zone_name": S("AvailabilityZoneName"), + } + owner_id: Optional[str] = field(default=None) + life_cycle_state: Optional[str] = field(default=None) + ip_address: Optional[str] = field(default=None) + availability_zone_name: Optional[str] = field(default=None) + + def connect_in_graph(self, builder: GraphBuilder, source: Json) -> None: + if nic_id := source.get("NetworkInterfaceId"): + builder.dependant_node(self, reverse=True, kind="aws_ec2_network_interface", id=nic_id) + + +@define(eq=False, slots=False) +class AwsEfsFileSystem(AwsResource, BaseVolume, EfsTaggable): + kind: ClassVar[str] = "aws_efs_file_system" + api_spec: ClassVar[AwsApiSpec] = AwsApiSpec("efs", "describe-file-systems", "FileSystems") + mapping: ClassVar[Dict[str, Bender]] = { + "id": S("FileSystemId"), + "tags": S("Tags", default=[]) >> ToDict(), + "name": S("Name"), + "ctime": S("CreationTime"), + "owner_id": S("OwnerId"), + "creation_token": S("CreationToken"), + "arn": S("FileSystemArn"), + "volume_status": S("LifeCycleState") + >> MapValue( + { + "creating": "busy", + "available": "available", + "updating": "busy", + "deleting": "busy", + "deleted": "deleted", + "error": "error", + }, + default="unknown", + ), + "number_of_mount_targets": S("NumberOfMountTargets"), + "volume_size": S("SizeInBytes", "Value") >> F(lambda x: x / 1024**3), + "performance_mode": S("PerformanceMode"), + "volume_encrypted": S("Encrypted"), + "throughput_mode": S("ThroughputMode"), + "provisioned_throughput_in_mibps": S("ProvisionedThroughputInMibps"), + "availability_zone_name": S("AvailabilityZoneName"), + } + owner_id: Optional[str] = field(default=None) + creation_token: Optional[str] = field(default=None) + number_of_mount_targets: Optional[int] = field(default=None) + performance_mode: Optional[str] = field(default=None) + throughput_mode: Optional[str] = field(default=None) + provisioned_throughput_in_mibps: Optional[float] = field(default=None) + availability_zone_name: Optional[str] = field(default=None) + + @classmethod + def called_collect_apis(cls) -> List[AwsApiSpec]: + return [cls.api_spec, AwsApiSpec("efs", "describe-mount-targets")] + + @classmethod + def collect(cls: Type[AwsResource], json: List[Json], builder: GraphBuilder) -> None: + def collect_mount_points(fs: AwsEfsFileSystem) -> None: + for mt_raw in builder.client.list("efs", "describe-mount-targets", "MountTargets", FileSystemId=fs.id): + mt = AwsEfsMountTarget.from_api(mt_raw) + builder.add_node(mt, mt_raw) + builder.add_edge(fs, node=mt) + + for js in json: + instance = cls.from_api(js) + builder.add_node(instance, js) + builder.submit_work(collect_mount_points, instance) + + def connect_in_graph(self, builder: GraphBuilder, source: Json) -> None: + if kms_key_id := source.get("KmsKeyId"): + builder.dependant_node(from_node=self, clazz=AwsKmsKey, id=AwsKmsKey.normalise_id(kms_key_id)) + + +@define(eq=False, slots=False) +class AwsEfsPosixUser: + kind: ClassVar[str] = "aws_efs_posix_user" + mapping: ClassVar[Dict[str, Bender]] = { + "uid": S("Uid"), + "gid": S("Gid"), + "secondary_gids": S("SecondaryGids", default=[]), + } + uid: Optional[int] = field(default=None) + gid: Optional[int] = field(default=None) + secondary_gids: List[int] = field(factory=list) + + +@define(eq=False, slots=False) +class AwsEfsCreationInfo: + kind: ClassVar[str] = "aws_efs_creation_info" + mapping: ClassVar[Dict[str, Bender]] = { + "owner_uid": S("OwnerUid"), + "owner_gid": S("OwnerGid"), + "permissions": S("Permissions"), + } + owner_uid: Optional[int] = field(default=None) + owner_gid: Optional[int] = field(default=None) + permissions: Optional[str] = field(default=None) + + +@define(eq=False, slots=False) +class AwsEfsRootDirectory: + kind: ClassVar[str] = "aws_efs_root_directory" + mapping: ClassVar[Dict[str, Bender]] = { + "path": S("Path"), + "creation_info": S("CreationInfo") >> Bend(AwsEfsCreationInfo.mapping), + } + path: Optional[str] = field(default=None) + creation_info: Optional[AwsEfsCreationInfo] = field(default=None) + + +@define(eq=False, slots=False) +class AwsEfsAccessPoint(AwsResource, EfsTaggable): + kind: ClassVar[str] = "aws_efs_access_point" + api_spec: ClassVar[AwsApiSpec] = AwsApiSpec("efs", "describe-access-points", "AccessPoints") + mapping: ClassVar[Dict[str, Bender]] = { + "id": S("AccessPointId"), + "tags": S("Tags", default=[]) >> ToDict(), + "name": S("Name"), + "client_token": S("ClientToken"), + "arn": S("AccessPointArn"), + "posix_user": S("PosixUser") >> Bend(AwsEfsPosixUser.mapping), + "root_directory": S("RootDirectory") >> Bend(AwsEfsRootDirectory.mapping), + "owner_id": S("OwnerId"), + "life_cycle_state": S("LifeCycleState"), + } + client_token: Optional[str] = field(default=None) + posix_user: Optional[AwsEfsPosixUser] = field(default=None) + root_directory: Optional[AwsEfsRootDirectory] = field(default=None) + owner_id: Optional[str] = field(default=None) + life_cycle_state: Optional[str] = field(default=None) + + def connect_in_graph(self, builder: GraphBuilder, source: Json) -> None: + if fs_id := source.get("FileSystemId"): + builder.dependant_node( + from_node=self, reverse=True, delete_same_as_default=True, clazz=AwsEfsFileSystem, id=fs_id + ) + + +resources: List[Type[AwsResource]] = [AwsEfsFileSystem, AwsEfsAccessPoint] diff --git a/plugins/aws/resoto_plugin_aws/resource/iam.py b/plugins/aws/resoto_plugin_aws/resource/iam.py index cd39ac934..409bdc370 100644 --- a/plugins/aws/resoto_plugin_aws/resource/iam.py +++ b/plugins/aws/resoto_plugin_aws/resource/iam.py @@ -1,12 +1,14 @@ +import csv +import time from datetime import datetime -from typing import ClassVar, Dict, Optional, Type, List, Any +from typing import ClassVar, Dict, Optional, Type, List, Any, Callable from attrs import define, field +from resoto_plugin_aws.aws_client import AwsClient from resoto_plugin_aws.resource.base import AwsResource, GraphBuilder, AwsApiSpec from resoto_plugin_aws.resource.ec2 import AwsEc2IamInstanceProfile from resoto_plugin_aws.utils import ToDict - from resotolib.baseresources import ( BaseCertificate, BasePolicy, @@ -17,11 +19,11 @@ EdgeType, ModelReference, ) -from resotolib.json import from_json +from resotolib.graph import Graph +from resotolib.json import from_json, value_in_path from resotolib.json_bender import Bender, S, Bend, AsDate, Sort, bend, ForallBend, F from resotolib.types import Json -from resotolib.graph import Graph -from resoto_plugin_aws.aws_client import AwsClient +from resotolib.utils import parse_utc def iam_update_tag(resource: AwsResource, client: AwsClient, action: str, key: str, value: str, **kwargs: Any) -> bool: @@ -72,8 +74,8 @@ class AwsIamAttachedPermissionsBoundary: @define(eq=False, slots=False) class AwsIamRoleLastUsed: kind: ClassVar[str] = "aws_iam_role_last_used" - mapping: ClassVar[Dict[str, Bender]] = {"last_used_date": S("LastUsedDate"), "region": S("Region")} - last_used_date: Optional[datetime] = field(default=None) + mapping: ClassVar[Dict[str, Bender]] = {"last_used": S("LastUsedDate"), "region": S("Region")} + last_used: Optional[datetime] = field(default=None) region: Optional[str] = field(default=None) @@ -393,11 +395,13 @@ def called_mutator_apis(cls) -> List[AwsApiSpec]: class AwsIamAccessKeyLastUsed: kind: ClassVar[str] = "aws_iam_access_key_last_used" mapping: ClassVar[Dict[str, Bender]] = { - "last_used_date": S("LastUsedDate"), + "last_used": S("LastUsedDate"), + "last_rotated": S("LastRotated"), "service_name": S("ServiceName"), "region": S("Region"), } - last_used_date: Optional[datetime] = field(default=None) + last_used: Optional[datetime] = field(default=None) + last_rotated: Optional[datetime] = field(default=None) service_name: Optional[str] = field(default=None) region: Optional[str] = field(default=None) @@ -421,6 +425,112 @@ class AwsIamAccessKey(AwsResource, BaseAccessKey): access_key_last_used: Optional[AwsIamAccessKeyLastUsed] = field(default=None) +class CredentialReportLine: + undefined = {"not_supported", "N/A"} + + def __init__(self, line: Dict[str, str]) -> None: + self.line = line + + def add_root_user(self, builder: GraphBuilder) -> None: + user = AwsRootUser( + id="root", + name="root", + arn=self.value_of("arn"), + ctime=self.value_of("user_creation_time", parse_utc), + password_enabled=self.password_enabled(), + password_last_used=self.password_last_used(), + password_last_changed=self.password_last_changed(), + password_next_rotation=self.password_next_rotation(), + mfa_active=self.mfa_active(), + ) + builder.add_node(user) + for key in self.access_keys(): + if key.access_key_status == "Active" or key.access_key_last_used is not None: + builder.add_node(key) + builder.add_edge(user, node=key) + + def access_keys(self) -> List[AwsIamAccessKey]: + def by_index(i: int) -> AwsIamAccessKey: + last_used = self.value_of(f"access_key_{i}_last_used_date", parse_utc) + service_name = self.value_of(f"access_key_{i}_last_used_service") + region = self.value_of(f"access_key_{i}_last_used_region") + last_rotated = self.value_of(f"access_key_{i}_last_rotated", parse_utc) + last_used = ( + None + if last_used is None and service_name is None and region is None and last_rotated is None + else AwsIamAccessKeyLastUsed( + last_used=last_used, last_rotated=last_rotated, service_name=service_name, region=region + ) + ) + return AwsIamAccessKey( + id=f"root_key_{i}", + name=f"root_key_{i}", + access_key_status="Active" if self.value_of(f"access_key_{i}_active") == "true" else "Inactive", + atime=last_used.last_used if last_used else None, + access_key_last_used=last_used, + ) + + # the report holds 2 entries + return [by_index(idx) for idx in range(1, 3)] + + def value_of(self, k: str, fn: Optional[Callable[[str], Any]] = None) -> Any: + v = self.line.get(k) + return None if v is None or v in self.undefined else (fn(v) if fn else v) + + def password_enabled(self) -> bool: + return self.value_of("password_enabled") == "true" # type: ignore + + def password_last_used(self) -> Optional[datetime]: + return self.value_of("password_last_used", parse_utc) # type: ignore + + def password_last_changed(self) -> Optional[datetime]: + return self.value_of("password_last_changed", parse_utc) # type: ignore + + def password_next_rotation(self) -> Optional[datetime]: + return self.value_of("password_next_rotation", parse_utc) # type: ignore + + def mfa_active(self) -> bool: + return self.value_of("mfa_active") == "true" # type: ignore + + @staticmethod + def user_lines(builder: GraphBuilder) -> Dict[str, "CredentialReportLine"]: + # wait for the report to be done + while (res := builder.client.get("iam", "generate-credential-report")) and res.get("State") != "COMPLETE": + time.sleep(1) + # fetch the report + report = builder.client.get("iam", "get-credential-report") + return CredentialReportLine.from_str(report["Content"]) if report else {} + + @staticmethod + def from_str(lines: str) -> Dict[str, "CredentialReportLine"]: + return {i["user"]: CredentialReportLine(i) for i in csv.DictReader(lines.splitlines(), delimiter=",")} + + +@define(eq=False, slots=False) +class AwsIamVirtualMfaDevice: + kind: ClassVar[str] = "aws_iam_virtual_mfa_device" + mapping: ClassVar[Dict[str, Bender]] = { + "serial_number": S("SerialNumber"), + "enable_date": S("EnableDate"), + } + serial_number: Optional[str] = field(default=None) + enable_date: Optional[datetime] = field(default=None) + + +@define(eq=False, slots=False) +class AwsRootUser(AwsResource, BaseUser): + kind: ClassVar[str] = "aws_root_user" + reference_kinds: ClassVar[ModelReference] = { + "predecessors": {"default": ["aws_account"]}, + } + password_enabled: Optional[bool] = field(default=None) + password_last_used: Optional[datetime] = field(default=None) + password_last_changed: Optional[datetime] = field(default=None) + password_next_rotation: Optional[datetime] = field(default=None) + mfa_active: Optional[bool] = field(default=None) + user_virtual_mfa_devices: Optional[List[AwsIamVirtualMfaDevice]] = field(default=None) + + @define(eq=False, slots=False) class AwsIamUser(AwsResource, BaseUser): kind: ClassVar[str] = "aws_iam_user" @@ -434,7 +544,6 @@ class AwsIamUser(AwsResource, BaseUser): "tags": S("Tags", default=[]) >> ToDict(), "name": S("UserName"), "ctime": S("CreateDate"), - "atime": S("PasswordLastUsed"), "path": S("Path"), "arn": S("Arn"), "user_policies": S("UserPolicyList", default=[]) >> ForallBend(AwsIamPolicyDetail.mapping), @@ -443,6 +552,12 @@ class AwsIamUser(AwsResource, BaseUser): path: Optional[str] = field(default=None) user_policies: List[AwsIamPolicyDetail] = field(factory=list) user_permissions_boundary: Optional[AwsIamAttachedPermissionsBoundary] = field(default=None) + password_enabled: Optional[bool] = field(default=None) + password_last_used: Optional[datetime] = field(default=None) + password_last_changed: Optional[datetime] = field(default=None) + password_next_rotation: Optional[datetime] = field(default=None) + mfa_active: Optional[bool] = field(default=None) + user_virtual_mfa_devices: Optional[List[AwsIamVirtualMfaDevice]] = field(default=None) @classmethod def called_collect_apis(cls) -> List[AwsApiSpec]: @@ -450,15 +565,26 @@ def called_collect_apis(cls) -> List[AwsApiSpec]: cls.api_spec, AwsApiSpec("iam", "list-access-keys"), AwsApiSpec("iam", "get-access-key-last-used"), - AwsApiSpec("iam", "list-users"), + AwsApiSpec("iam", "generate-credential-report"), + AwsApiSpec("iam", "get-credential-report"), ] + @classmethod + def collect_resources(cls: Type[AwsResource], builder: GraphBuilder) -> None: + # start generation of the credentials resport and pick it up later + builder.client.get("iam", "generate-credential-report") + # let super handle the rest (this will take some time for the report to be done) + super().collect_resources(builder) # type: ignore # mypy bug: https://github.com/python/mypy/issues/12885 + @classmethod def collect(cls: Type[AwsResource], json_list: List[Json], builder: GraphBuilder) -> None: - name_password_last_used_map: Dict[str, str] = {} - for user in builder.client.list("iam", "list-users", "Users"): - if "PasswordLastUsed" in user and "UserId" in user: - name_password_last_used_map[user["UserId"]] = user["PasswordLastUsed"] + # retrieve the created report + report = CredentialReportLine.user_lines(builder) + + # the root user is not listed in IAM users, so we need to add it manually + if root_user := report.get(""): + root_user.add_root_user(builder) + for json in json_list: for js in json.get("GroupDetailList", []): builder.add_node(AwsIamGroup.from_api(js), js) @@ -470,21 +596,41 @@ def collect(cls: Type[AwsResource], json_list: List[Json], builder: GraphBuilder builder.add_node(AwsIamPolicy.from_api(js), js) for js in json.get("UserDetailList", []): - js["PasswordLastUsed"] = name_password_last_used_map.get(js["UserId"]) user = AwsIamUser.from_api(js) builder.add_node(user, js) + line = report.get(user.name or user.id) + line_keys: List[AwsIamAccessKey] = [] + if line: + user.password_enabled = line.password_enabled() + user.password_last_used = line.password_last_used() + user.atime = user.password_last_used + user.password_last_changed = line.password_last_changed() + user.password_next_rotation = line.password_next_rotation() + user.mfa_active = line.mfa_active() + line_keys = line.access_keys() # add all iam access keys for this user - for ak in builder.client.list("iam", "list-access-keys", "AccessKeyMetadata", UserName=user.name): + for idx, ak in enumerate( + builder.client.list("iam", "list-access-keys", "AccessKeyMetadata", UserName=user.name) + ): key = AwsIamAccessKey.from_api(ak) - # get last used date for this key - if lu := builder.client.get( - "iam", "get-access-key-last-used", "AccessKeyLastUsed", AccessKeyId=key.id - ): - key.access_key_last_used = AwsIamAccessKeyLastUsed.from_api(lu) - key.atime = key.access_key_last_used.last_used_date if key.access_key_last_used else None + if line and idx < len(line_keys): + key.access_key_last_used = line_keys[idx].access_key_last_used builder.add_node(key, ak) builder.dependant_node(user, node=key) + def add_virtual_mfa_devices() -> None: + for vjs in builder.client.list("iam", "list-virtual-mfa-devices", "VirtualMFADevices"): + if arn := value_in_path(vjs, "User.Arn"): + if isinstance(usr := builder.node(arn=arn), (AwsIamUser, AwsRootUser)): + mapped = bend(AwsIamVirtualMfaDevice.mapping, vjs) + node = from_json(mapped, AwsIamVirtualMfaDevice) + if usr.user_virtual_mfa_devices is None: + usr.user_virtual_mfa_devices = [] + usr.user_virtual_mfa_devices.append(node) + + if builder.account.mfa_devices is not None and builder.account.mfa_devices > 0: + builder.submit_work(add_virtual_mfa_devices) + def connect_in_graph(self, builder: GraphBuilder, source: Json) -> None: for p in bend(S("AttachedManagedPolicies", default=[]), source): builder.dependant_node(self, clazz=AwsIamPolicy, delete_same_as_default=True, arn=p.get("PolicyArn")) diff --git a/plugins/aws/resoto_plugin_aws/resource/s3.py b/plugins/aws/resoto_plugin_aws/resource/s3.py index dbe578893..b33feacbf 100644 --- a/plugins/aws/resoto_plugin_aws/resource/s3.py +++ b/plugins/aws/resoto_plugin_aws/resource/s3.py @@ -1,15 +1,47 @@ +from contextlib import suppress from typing import ClassVar, Dict, List, Type, Optional, cast, Any +from attr import field from attrs import define +from json import loads as json_loads from resoto_plugin_aws.aws_client import AwsClient from resoto_plugin_aws.resource.base import AwsResource, AwsApiSpec, GraphBuilder from resoto_plugin_aws.utils import tags_as_dict -from resotolib.baseresources import BaseBucket -from resotolib.json_bender import Bender, S +from resotolib.json import from_json +from resotolib.baseresources import BaseBucket, PhantomBaseResource, ModelReference +from resotolib.json_bender import Bender, S, bend from resotolib.types import Json +@define(eq=False, slots=False) +class AwsS3ServerSideEncryptionRule: + kind: ClassVar[str] = "aws_s3_server_side_encryption_rule" + mapping: ClassVar[Dict[str, Bender]] = { + "sse_algorithm": S("ApplyServerSideEncryptionByDefault", "SSEAlgorithm"), + "kms_master_key_id": S("ApplyServerSideEncryptionByDefault", "KMSMasterKeyID"), + "bucket_key_enabled": S("BucketKeyEnabled"), + } + sse_algorithm: Optional[str] = field(default=None) + kms_master_key_id: Optional[str] = field(default=None) + bucket_key_enabled: Optional[bool] = field(default=None) + + +@define(eq=False, slots=False) +class AwsS3PublicAccessBlockConfiguration: + kind: ClassVar[str] = "aws_s3_public_access_block_configuration" + mapping: ClassVar[Dict[str, Bender]] = { + "block_public_acls": S("BlockPublicAcls"), + "ignore_public_acls": S("IgnorePublicAcls"), + "block_public_policy": S("BlockPublicPolicy"), + "restrict_public_buckets": S("RestrictPublicBuckets"), + } + block_public_acls: Optional[bool] = field(default=False) + ignore_public_acls: Optional[bool] = field(default=False) + block_public_policy: Optional[bool] = field(default=False) + restrict_public_buckets: Optional[bool] = field(default=False) + + @define(eq=False, slots=False) class AwsS3Bucket(AwsResource, BaseBucket): kind: ClassVar[str] = "aws_s3_bucket" @@ -17,23 +49,84 @@ class AwsS3Bucket(AwsResource, BaseBucket): "s3", "list-buckets", "Buckets", override_iam_permission="s3:ListAllMyBuckets" ) mapping: ClassVar[Dict[str, Bender]] = {"id": S("Name"), "name": S("Name"), "ctime": S("CreationDate")} + bucket_encryption_rules: Optional[List[AwsS3ServerSideEncryptionRule]] = field(default=None) + bucket_policy: Optional[Json] = field(default=None) + bucket_versioning: Optional[bool] = field(default=None) + bucket_mfa_delete: Optional[bool] = field(default=None) + bucket_public_access_block_configuration: Optional[AwsS3PublicAccessBlockConfiguration] = field(default=None) @classmethod def called_collect_apis(cls) -> List[AwsApiSpec]: - return [cls.api_spec, AwsApiSpec("s3", "get-bucket-tagging")] + return [ + cls.api_spec, + AwsApiSpec("s3", "get-bucket-tagging"), + AwsApiSpec("s3", "get-bucket-encryption"), + AwsApiSpec("s3", "get-bucket-policy"), + AwsApiSpec("s3", "get-bucket-versioning"), + AwsApiSpec("s3", "get-public-access-block"), + ] @classmethod def collect(cls: Type[AwsResource], json: List[Json], builder: GraphBuilder) -> None: - def add_tags(bucket: AwsS3Bucket, client: AwsClient) -> None: - tags = bucket._get_tags(client) + def add_tags(bucket: AwsS3Bucket) -> None: + tags = bucket._get_tags(builder.client) if tags: bucket.tags = cast(Dict[str, Optional[str]], tags) + def add_bucket_encryption(bck: AwsS3Bucket) -> None: + bck.bucket_encryption_rules = [] + for raw in builder.client.list( + "s3", + "get-bucket-encryption", + "ServerSideEncryptionConfiguration.Rules", + Bucket=bck.name, + expected_errors=["ServerSideEncryptionConfigurationNotFoundError"], + ): + mapped = bend(AwsS3ServerSideEncryptionRule.mapping, raw) + bck.bucket_encryption_rules.append(from_json(mapped, AwsS3ServerSideEncryptionRule)) + + def add_bucket_policy(bck: AwsS3Bucket) -> None: + if raw_policy := builder.client.get( + "s3", + "get-bucket-policy", + "Policy", + Bucket=bck.name, + expected_errors=["NoSuchBucketPolicy"], + ): + bck.bucket_policy = json_loads(raw_policy) # type: ignore # this is a string + + def add_bucket_versioning(bck: AwsS3Bucket) -> None: + with suppress(Exception): + if raw_versioning := builder.client.get("s3", "get-bucket-versioning", None, Bucket=bck.name): + bck.bucket_versioning = raw_versioning.get("Status") == "Enabled" + bck.bucket_mfa_delete = raw_versioning.get("MFADelete") == "Enabled" + else: + bck.bucket_versioning = False + bck.bucket_mfa_delete = False + + def add_public_access(bck: AwsS3Bucket) -> None: + with suppress(Exception): + if raw_access := builder.client.get( + "s3", + "get-public-access-block", + "PublicAccessBlockConfiguration", + Bucket=bck.name, + expected_errors=["NoSuchPublicAccessBlockConfiguration"], + ): + mapped = bend(AwsS3PublicAccessBlockConfiguration.mapping, raw_access) + bck.bucket_public_access_block_configuration = from_json( + mapped, AwsS3PublicAccessBlockConfiguration + ) + for js in json: bucket = cls.from_api(js) bucket.set_arn(builder=builder, region="", account="", resource=bucket.safe_name) builder.add_node(bucket, js) - builder.submit_work(add_tags, bucket, builder.client) + builder.submit_work(add_tags, bucket) + builder.submit_work(add_bucket_encryption, bucket) + builder.submit_work(add_bucket_policy, bucket) + builder.submit_work(add_bucket_versioning, bucket) + builder.submit_work(add_public_access, bucket) def _set_tags(self, client: AwsClient, tags: Dict[str, str]) -> bool: tag_set = [{"Key": k, "Value": v} for k, v in tags.items()] @@ -104,4 +197,41 @@ def name_from_path(path_or_uri: str) -> str: return path_or_uri -resources: List[Type[AwsResource]] = [AwsS3Bucket] +@define(eq=False) +class AwsS3AccountSettings(AwsResource, PhantomBaseResource): + """ + This resource is fetched once for every account. + """ + + kind: ClassVar[str] = "aws_s3_account_settings" + reference_kinds: ClassVar[ModelReference] = { + "successors": {"default": ["aws_account"]}, + } + api_spec: ClassVar[AwsApiSpec] = AwsApiSpec( + "s3control", "get-public-access-block", "PublicAccessBlockConfiguration" + ) + + bucket_public_access_block_configuration: Optional[AwsS3PublicAccessBlockConfiguration] = field(default=None) + + @classmethod + def collect_resources(cls: Type[AwsResource], builder: GraphBuilder) -> None: + node = AwsS3AccountSettings( + id=builder.account.id, + name=builder.account.name, + ctime=builder.account.ctime, + bucket_public_access_block_configuration=AwsS3PublicAccessBlockConfiguration(), + ) + if raw := builder.client.get( + "s3control", + "get-public-access-block", + "PublicAccessBlockConfiguration", + AccountId=builder.account.id, + expected_errors=["NoSuchPublicAccessBlockConfiguration"], + ): + mapped = bend(AwsS3PublicAccessBlockConfiguration.mapping, raw) + node.bucket_public_access_block_configuration = from_json(mapped, AwsS3PublicAccessBlockConfiguration) + builder.add_node(node) + builder.add_edge(builder.account, node=node) + + +resources: List[Type[AwsResource]] = [AwsS3AccountSettings, AwsS3Bucket] diff --git a/plugins/aws/test/collector_test.py b/plugins/aws/test/collector_test.py index a4c9eab5b..3f06aa715 100644 --- a/plugins/aws/test/collector_test.py +++ b/plugins/aws/test/collector_test.py @@ -30,8 +30,8 @@ def count_kind(clazz: Type[AwsResource]) -> int: # make sure all threads have been joined assert len(threading.enumerate()) == 1 # ensure the correct number of nodes and edges - assert count_kind(AwsResource) == 188 - assert len(account_collector.graph.edges) == 442 + assert count_kind(AwsResource) == 195 + assert len(account_collector.graph.edges) == 454 def test_dependencies() -> None: diff --git a/plugins/aws/test/resources/__init__.py b/plugins/aws/test/resources/__init__.py index 0e0c0aa70..19c44baf9 100644 --- a/plugins/aws/test/resources/__init__.py +++ b/plugins/aws/test/resources/__init__.py @@ -78,7 +78,7 @@ def call_action(*args: Any, **kwargs: Any) -> Any: with open(path) as f: return json.load(f) else: - # print(f"Not found: {path}") + print(f"Not found: {path}") return {} return call_action @@ -141,7 +141,8 @@ def build_graph(cls: Type[AwsResourceType], region_name: Optional[str] = None) - queue = ExecutorQueue(DummyExecutor(), "test") region = AwsRegion(id="eu-central-1", name=(region_name or "eu-central-1")) feedback = CoreFeedback("test", "test", "collect", Queue()) - builder = GraphBuilder(Graph(), Cloud(id="test"), AwsAccount(id="test"), region, client, queue, feedback) + account = AwsAccount(id="test", mfa_devices=12, mfa_devices_in_use=12) + builder = GraphBuilder(Graph(), Cloud(id="test"), account, region, client, queue, feedback) cls.collect_resources(builder) builder.executor.wait_for_submitted_work() return builder @@ -162,8 +163,8 @@ def round_trip_for( builder = build_graph(cls, region_name=region_name) assert len(builder.graph.nodes) > 0 for node, data in builder.graph.nodes(data=True): - node.connect_in_graph(builder, data["source"]) + node.connect_in_graph(builder, data.get("source", {})) check_single_node(node) - first = next(iter(builder.graph.nodes)) + first = next(iter(builder.resources_of(cls))) all_props_set(first, set(ignore_props)) return first, builder diff --git a/plugins/aws/test/resources/efs_test.py b/plugins/aws/test/resources/efs_test.py new file mode 100644 index 000000000..2bee224e7 --- /dev/null +++ b/plugins/aws/test/resources/efs_test.py @@ -0,0 +1,11 @@ +from resoto_plugin_aws.resource.efs import AwsEfsFileSystem, AwsEfsMountTarget, AwsEfsAccessPoint +from test.resources import round_trip_for + + +def test_efs_filesystem() -> None: + first, builder = round_trip_for(AwsEfsFileSystem, "volume_iops", "volume_throughput") + assert len(builder.resources_of(AwsEfsMountTarget)) == 2 + + +def test_efs_access_points() -> None: + round_trip_for(AwsEfsAccessPoint) diff --git a/plugins/aws/test/resources/files/efs/describe-access-points.json b/plugins/aws/test/resources/files/efs/describe-access-points.json new file mode 100644 index 000000000..c7d56166f --- /dev/null +++ b/plugins/aws/test/resources/files/efs/describe-access-points.json @@ -0,0 +1,36 @@ +{ + "AccessPoints": [ + { + "ClientToken": "foo", + "Name": "foo", + "Tags": [ + { + "Key": "foo", + "Value": "bla" + } + ], + "AccessPointId": "ap-1", + "AccessPointArn": "ap-1", + "FileSystemId": "fs-1", + "PosixUser": { + "Uid": 123, + "Gid": 123, + "SecondaryGids": [ + 123, + 123, + 123 + ] + }, + "RootDirectory": { + "Path": "foo", + "CreationInfo": { + "OwnerUid": 123, + "OwnerGid": 123, + "Permissions": "foo" + } + }, + "OwnerId": "foo", + "LifeCycleState": "available" + } + ] +} diff --git a/plugins/aws/test/resources/files/efs/describe-file-systems.json b/plugins/aws/test/resources/files/efs/describe-file-systems.json new file mode 100644 index 000000000..18c379cfb --- /dev/null +++ b/plugins/aws/test/resources/files/efs/describe-file-systems.json @@ -0,0 +1,66 @@ +{ + "FileSystems": [ + { + "OwnerId": "test", + "CreationToken": "test123", + "FileSystemId": "fs-1", + "FileSystemArn": "arn:aws:elasticfilesystem:us-west-2:test:file-system/fs-1", + "CreationTime": "2020-04-30T17:25:06+02:00", + "LifeCycleState": "available", + "Name": "prod-efs", + "NumberOfMountTargets": 3, + "SizeInBytes": { + "Value": 1269075675136, + "Timestamp": "2023-01-30T12:52:56+01:00", + "ValueInIA": 0, + "ValueInStandard": 1269075675136 + }, + "PerformanceMode": "generalPurpose", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-west-2:test:key/key1", + "ThroughputMode": "bursting", + "ProvisionedThroughputInMibps": 23.123, + "AvailabilityZoneName": "us-west-2a", + "Tags": [ + { + "Key": "foo", + "Value": "bla" + }, + { + "Key": "owner", + "Value": "dev" + } + ] + }, + { + "OwnerId": "test", + "CreationToken": "test234", + "FileSystemId": "fs-2", + "FileSystemArn": "arn:aws:elasticfilesystem:us-west-2:test:file-system/fs-2", + "CreationTime": "2020-03-31T09:31:47+02:00", + "LifeCycleState": "available", + "Name": "dev-efs", + "NumberOfMountTargets": 3, + "SizeInBytes": { + "Value": 49152, + "Timestamp": "2023-01-30T12:56:06+01:00", + "ValueInIA": 0, + "ValueInStandard": 49152 + }, + "PerformanceMode": "generalPurpose", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-west-2:test:key/key2", + "ThroughputMode": "bursting", + "Tags": [ + { + "Key": "foo", + "Value": "bla" + }, + { + "Key": "owner", + "Value": "dev" + } + ] + } + ] +} diff --git a/plugins/aws/test/resources/files/efs/describe-mount-targets__fs_1.json b/plugins/aws/test/resources/files/efs/describe-mount-targets__fs_1.json new file mode 100644 index 000000000..0bb6cda17 --- /dev/null +++ b/plugins/aws/test/resources/files/efs/describe-mount-targets__fs_1.json @@ -0,0 +1,28 @@ +{ + "MountTargets": [ + { + "OwnerId": "test", + "MountTargetId": "fsmt-1", + "FileSystemId": "fs-1", + "SubnetId": "subnet-1", + "LifeCycleState": "available", + "IpAddress": "10.0.135.232", + "NetworkInterfaceId": "eni-1", + "AvailabilityZoneId": "usw2-az2", + "AvailabilityZoneName": "us-west-2b", + "VpcId": "vpc-1" + }, + { + "OwnerId": "test", + "MountTargetId": "fsmt-2", + "FileSystemId": "fs-1", + "SubnetId": "subnet-2", + "LifeCycleState": "available", + "IpAddress": "10.0.131.41", + "NetworkInterfaceId": "eni-2", + "AvailabilityZoneId": "usw2-az1", + "AvailabilityZoneName": "us-west-2a", + "VpcId": "vpc-1" + } + ] +} diff --git a/plugins/aws/test/resources/files/iam/generate-credential-report.json b/plugins/aws/test/resources/files/iam/generate-credential-report.json new file mode 100644 index 000000000..498cb9966 --- /dev/null +++ b/plugins/aws/test/resources/files/iam/generate-credential-report.json @@ -0,0 +1,3 @@ +{ + "State": "COMPLETE" +} diff --git a/plugins/aws/test/resources/files/iam/get-account-authorization-details.json b/plugins/aws/test/resources/files/iam/get-account-authorization-details.json index 0d682055d..30e034a3f 100644 --- a/plugins/aws/test/resources/files/iam/get-account-authorization-details.json +++ b/plugins/aws/test/resources/files/iam/get-account-authorization-details.json @@ -19,6 +19,10 @@ } } ], + "PermissionsBoundary": { + "PermissionsBoundaryType": "PermissionsBoundaryPolicy", + "PermissionsBoundaryArn": "string" + }, "GroupList": [], "AttachedManagedPolicies": [], "Tags": [ diff --git a/plugins/aws/test/resources/files/iam/get-credential-report.json b/plugins/aws/test/resources/files/iam/get-credential-report.json new file mode 100644 index 000000000..7dab32070 --- /dev/null +++ b/plugins/aws/test/resources/files/iam/get-credential-report.json @@ -0,0 +1,5 @@ +{ + "Content": "user,arn,user_creation_time,password_enabled,password_last_used,password_last_changed,password_next_rotation,mfa_active,access_key_1_active,access_key_1_last_rotated,access_key_1_last_used_date,access_key_1_last_used_region,access_key_1_last_used_service,access_key_2_active,access_key_2_last_rotated,access_key_2_last_used_date,access_key_2_last_used_region,access_key_2_last_used_service,cert_1_active,cert_1_last_rotated,cert_2_active,cert_2_last_rotated\n,arn:aws:iam::test:root,2021-09-02T15:15:38+00:00,not_supported,2022-11-30T15:31:34+00:00,not_supported,not_supported,TRUE,FALSE,N/A,N/A,N/A,N/A,FALSE,N/A,N/A,N/A,N/A,FALSE,N/A,FALSE,N/A\nmatthias,arn:aws:iam::test:user/matthias,2022-05-06T08:23:17+00:00,TRUE,2022-12-08T09:34:46+00:00,2022-05-06T08:30:03+00:00,2022-05-06T08:30:03+00:00,TRUE,TRUE,2022-05-06T08:23:18+00:00,2023-01-25T10:11:00+00:00,us-east-1,iam,FALSE,N/A,N/A,N/A,N/A,FALSE,N/A,FALSE,N/A\nResoto,arn:aws:iam::test:user/Resoto,2022-07-08T14:23:42+00:00,FALSE,N/A,N/A,N/A,FALSE,TRUE,2022-07-08T14:23:43+00:00,2022-08-02T09:02:00+00:00,us-west-1,ec2,TRUE,2022-10-20T13:30:38+00:00,2022-12-21T12:01:00+00:00,us-east-2,elasticloadbalancing,FALSE,N/A,FALSE,N/A\ntest_user,arn:aws:iam::test:user/matthias,2022-05-06T08:23:17+00:00,TRUE,2022-12-08T09:34:46+00:00,2022-05-06T08:30:03+00:00,2022-05-06T08:30:03+00:00,TRUE,TRUE,2022-05-06T08:23:18+00:00,2023-01-25T10:11:00+00:00,us-east-1,iam,FALSE,N/A,N/A,N/A,N/A,FALSE,N/A,FALSE,N/A\nak_test,arn:aws:iam::test:user/matthias,2022-05-06T08:23:17+00:00,TRUE,2022-12-08T09:34:46+00:00,2022-05-06T08:30:03+00:00,2022-05-06T08:30:03+00:00,TRUE,TRUE,2022-05-06T08:23:18+00:00,2023-01-25T10:11:00+00:00,us-east-1,iam,FALSE,N/A,N/A,N/A,N/A,FALSE,N/A,FALSE,N/A\n", + "ReportFormat": "text/csv", + "GeneratedTime": "2023-01-25T10:45:20+00:00" +} diff --git a/plugins/aws/test/resources/files/iam/list-users.json b/plugins/aws/test/resources/files/iam/list-users.json deleted file mode 100644 index 06b02a1ea..000000000 --- a/plugins/aws/test/resources/files/iam/list-users.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "Users": [ - { - "Path": "string", - "UserName": "test_user", - "UserId": "user-123", - "Arn": "string", - "CreateDate": "2018-08-16T13:32:10+00:00", - "PasswordLastUsed": "2018-08-16T13:32:10+00:00", - "PermissionsBoundary": { - "PermissionsBoundaryType": "PermissionsBoundaryPolicy", - "PermissionsBoundaryArn": "string" - }, - "Tags": [ - { - "Key": "string", - "Value": "string" - } - ] - }, - { - "Path": "string", - "UserName": "ReposSync", - "UserId": "user-234", - "Arn": "string", - "CreateDate": "2018-08-16T13:32:10+00:00", - "PermissionsBoundary": { - "PermissionsBoundaryType": "PermissionsBoundaryPolicy", - "PermissionsBoundaryArn": "string" - }, - "Tags": [ - { - "Key": "string", - "Value": "string" - } - ] - }, - { - "Path": "string", - "UserName": "SaltCloud", - "UserId": "user-345", - "Arn": "string", - "CreateDate": "2018-08-16T13:32:10+00:00", - "PasswordLastUsed": "2018-08-16T13:32:10+00:00", - "PermissionsBoundary": { - "PermissionsBoundaryType": "PermissionsBoundaryPolicy", - "PermissionsBoundaryArn": "string" - }, - "Tags": [ - { - "Key": "string", - "Value": "string" - } - ] - } - ], - "IsTruncated": false, - "Marker": "string" -} diff --git a/plugins/aws/test/resources/files/iam/list-virtual-mfa-devices.json b/plugins/aws/test/resources/files/iam/list-virtual-mfa-devices.json new file mode 100644 index 000000000..eaf2b177a --- /dev/null +++ b/plugins/aws/test/resources/files/iam/list-virtual-mfa-devices.json @@ -0,0 +1,27 @@ +{ + "VirtualMFADevices": [ + { + "SerialNumber": "arn:aws:iam::test:mfa/root-account-mfa-device", + "User": { + "UserName": "test", + "UserId": "test", + "Arn": "arn:aws:iam::test:root", + "CreateDate": "2021-09-02T15:15:38+00:00", + "PasswordLastUsed": "2022-11-30T15:31:34+00:00" + }, + "EnableDate": "2021-09-02T15:25:06+00:00" + }, + { + "SerialNumber": "arn:aws:iam::test:mfa/matthias", + "User": { + "Path": "/", + "UserName": "test_user", + "UserId": "user-123", + "Arn": "arn:aws:iam::test:user/test_user", + "CreateDate": "2022-05-06T08:23:17+00:00", + "PasswordLastUsed": "2022-12-08T09:34:46+00:00" + }, + "EnableDate": "2022-05-06T08:35:09+00:00" + } + ] +} diff --git a/plugins/aws/test/resources/files/s3/get-bucket-encryption__bucket_1.json b/plugins/aws/test/resources/files/s3/get-bucket-encryption__bucket_1.json new file mode 100644 index 000000000..a3eba7ec4 --- /dev/null +++ b/plugins/aws/test/resources/files/s3/get-bucket-encryption__bucket_1.json @@ -0,0 +1,12 @@ +{ + "ServerSideEncryptionConfiguration": { + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + }, + "BucketKeyEnabled": false + } + ] + } +} diff --git a/plugins/aws/test/resources/files/s3/get-bucket-policy__bucket_1.json b/plugins/aws/test/resources/files/s3/get-bucket-policy__bucket_1.json new file mode 100644 index 000000000..adeaf5929 --- /dev/null +++ b/plugins/aws/test/resources/files/s3/get-bucket-policy__bucket_1.json @@ -0,0 +1,3 @@ +{ + "Policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"AWSCloudTrailAclCheck20150319\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"cloudtrail.amazonaws.com\"},\"Action\":\"s3:GetBucketAcl\",\"Resource\":\"arn:aws:s3:::someengineering-cloudtrail-logs\",\"Condition\":{\"StringEquals\":{\"AWS:SourceArn\":\"arn:aws:cloudtrail:us-east-2:882347060974:trail/management-events\"}}},{\"Sid\":\"AWSCloudTrailWrite20150319\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"cloudtrail.amazonaws.com\"},\"Action\":\"s3:PutObject\",\"Resource\":\"arn:aws:s3:::someengineering-cloudtrail-logs/AWSLogs/882347060974/*\",\"Condition\":{\"StringEquals\":{\"s3:x-amz-acl\":\"bucket-owner-full-control\",\"AWS:SourceArn\":\"arn:aws:cloudtrail:us-east-2:882347060974:trail/management-events\"}}},{\"Sid\":\"AWSCloudTrailWrite20150319\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"cloudtrail.amazonaws.com\"},\"Action\":\"s3:PutObject\",\"Resource\":\"arn:aws:s3:::someengineering-cloudtrail-logs/AWSLogs/o-cb1cfytdfn/*\",\"Condition\":{\"StringEquals\":{\"s3:x-amz-acl\":\"bucket-owner-full-control\",\"AWS:SourceArn\":\"arn:aws:cloudtrail:us-east-2:882347060974:trail/management-events\"}}}]}" +} diff --git a/plugins/aws/test/resources/files/s3/get-bucket-versioning__bucket_1.json b/plugins/aws/test/resources/files/s3/get-bucket-versioning__bucket_1.json new file mode 100644 index 000000000..7fbf85ac2 --- /dev/null +++ b/plugins/aws/test/resources/files/s3/get-bucket-versioning__bucket_1.json @@ -0,0 +1,3 @@ +{ + "Status": "Enabled" +} diff --git a/plugins/aws/test/resources/files/s3/get-public-access-block__bucket_1.json b/plugins/aws/test/resources/files/s3/get-public-access-block__bucket_1.json new file mode 100644 index 000000000..8e8fd45c2 --- /dev/null +++ b/plugins/aws/test/resources/files/s3/get-public-access-block__bucket_1.json @@ -0,0 +1,8 @@ +{ + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "IgnorePublicAcls": true, + "BlockPublicPolicy": true, + "RestrictPublicBuckets": true + } +} diff --git a/plugins/aws/test/resources/files/s3control/get-public-access-block__test.json b/plugins/aws/test/resources/files/s3control/get-public-access-block__test.json new file mode 100644 index 000000000..8e8fd45c2 --- /dev/null +++ b/plugins/aws/test/resources/files/s3control/get-public-access-block__test.json @@ -0,0 +1,8 @@ +{ + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "IgnorePublicAcls": true, + "BlockPublicPolicy": true, + "RestrictPublicBuckets": true + } +} diff --git a/plugins/aws/test/resources/iam_test.py b/plugins/aws/test/resources/iam_test.py index a1302a515..07f5944e2 100644 --- a/plugins/aws/test/resources/iam_test.py +++ b/plugins/aws/test/resources/iam_test.py @@ -1,3 +1,9 @@ +from datetime import datetime, timezone +from textwrap import dedent +from types import SimpleNamespace +from typing import Any, cast + +from resoto_plugin_aws.aws_client import AwsClient from resoto_plugin_aws.resource.iam import ( AwsIamPolicy, AwsIamGroup, @@ -6,11 +12,30 @@ AwsIamUser, AwsIamAccessKey, AwsIamInstanceProfile, + CredentialReportLine, ) from test.resources import round_trip_for -from typing import Any, cast -from types import SimpleNamespace -from resoto_plugin_aws.aws_client import AwsClient + + +def test_credentials_report() -> None: + csv = dedent( + """ + user,arn,user_creation_time,password_enabled,password_last_used,password_last_changed,password_next_rotation,mfa_active,access_key_1_active,access_key_1_last_rotated,access_key_1_last_used_date,access_key_1_last_used_region,access_key_1_last_used_service,access_key_2_active,access_key_2_last_rotated,access_key_2_last_used_date,access_key_2_last_used_region,access_key_2_last_used_service,cert_1_active,cert_1_last_rotated,cert_2_active,cert_2_last_rotated + a,arn:aws:iam::test:user/a,2021-05-06T08:23:17+00:00,true,2021-12-08T09:34:46+00:00,2021-05-06T08:30:03+00:00,N/A,true,true,2021-05-06T08:23:18+00:00,2023-01-25T10:11:00+00:00,us-east-1,iam,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A + b,arn:aws:iam::test:user/b,2022-05-06T08:23:17+00:00,false,2022-12-08T09:34:46+00:00,2022-05-06T08:30:03+00:00,N/A,false,false,2022-05-06T08:23:18+00:00,2023-01-25T10:11:00+00:00,eu-central-1,s3,true,2023-01-25T10:11:00+00:00,2023-01-25T10:11:00+00:00,eu-central-3,iam,false,N/A,false,N/A + """ + ).strip() + lines = CredentialReportLine.from_str(csv) + assert len(lines) == 2 + assert lines["a"].password_enabled() is True + assert lines["a"].password_last_used() == datetime(2021, 12, 8, 9, 34, 46, tzinfo=timezone.utc) + assert [a.access_key_last_used.service_name for a in lines["a"].access_keys() if a.access_key_last_used] == ["iam"] + assert lines["b"].password_enabled() is False + assert lines["b"].password_last_used() == datetime(2022, 12, 8, 9, 34, 46, tzinfo=timezone.utc) + assert [b.access_key_last_used.service_name for b in lines["b"].access_keys() if b.access_key_last_used] == [ + "s3", + "iam", + ] def test_server_certificates() -> None: @@ -33,7 +58,7 @@ def test_user_roles_groups_policies_keys() -> None: # keys ------------ assert len(builder.resources_of(AwsIamAccessKey)) == 2 assert (ak_test := builder.node(clazz=AwsIamAccessKey, id="ak_test")) is not None - assert len(builder.graph.nodes) == 12 + assert len(builder.graph.nodes) == 13 # make sure access keys are created and connected as part of the user assert builder.graph.has_edge(test_user, ak_test) diff --git a/plugins/aws/test/resources/s3_test.py b/plugins/aws/test/resources/s3_test.py index 2eadd705a..19fff70be 100644 --- a/plugins/aws/test/resources/s3_test.py +++ b/plugins/aws/test/resources/s3_test.py @@ -2,12 +2,13 @@ from types import SimpleNamespace from typing import cast, Any, Callable from resoto_plugin_aws.aws_client import AwsClient -from resoto_plugin_aws.resource.s3 import AwsS3Bucket +from resoto_plugin_aws.resource.s3 import AwsS3Bucket, AwsS3AccountSettings def test_buckets() -> None: first, builder = round_trip_for(AwsS3Bucket) assert len(builder.resources_of(AwsS3Bucket)) == 4 + assert len(first.bucket_encryption_rules or []) == 1 assert first.arn == "arn:aws:s3:::bucket-1" assert len(first.tags) == 1 @@ -18,6 +19,10 @@ def test_name_from_path() -> None: assert AwsS3Bucket.name_from_path("https://some-bucket.s3.region-code.amazonaws.com/key-name") == "some-bucket" +def test_s3_account_settings() -> None: + round_trip_for(AwsS3AccountSettings) + + def test_tagging() -> None: bucket, _ = round_trip_for(AwsS3Bucket) diff --git a/plugins/aws/tools/model_gen.py b/plugins/aws/tools/model_gen.py index 1a997442c..89e464831 100644 --- a/plugins/aws/tools/model_gen.py +++ b/plugins/aws/tools/model_gen.py @@ -89,7 +89,7 @@ def to_class(self) -> str: class AwsResotoModel: api_action: str # action to perform on the client result_property: str # this property holds the resulting list - result_shape: str # the shape of the result according to the service specification + result_shape: Optional[str] = None # the shape of the result according to the service specification prefix: Optional[str] = None # prefix for the resources prop_prefix: Optional[str] = None # prefix for the attributes name: Optional[str] = None # name of the clazz - uses the shape name by default @@ -233,10 +233,14 @@ def complex_simple_shape(s: Shape) -> Optional[Tuple[str, str]]: def all_models() -> List[AwsModel]: visited: Set[str] = set() result: List[AwsModel] = [] - for name, endpoint in models.items(): + for name, endpoints in models.items(): sm = service_model(name) - for ep in endpoint: - shape = sm.shape_for(ep.result_shape) + for ep in endpoints: + shape = ( + sm.shape_for(ep.result_shape) + if ep.result_shape + else sm.operation_model(pascalcase(ep.api_action)).output_shape + ) result.extend( clazz_model( shape, @@ -278,6 +282,8 @@ def sample(shape: Shape) -> JsonElement: return 123 elif shape.type_name == "boolean": return True + elif shape.type_name == "long": + return 123 else: raise NotImplementedError(f"Unsupported shape: {type(shape)}") @@ -296,7 +302,6 @@ def sample(shape: Shape) -> JsonElement: # "list-certificate-authorities", "CertificateAuthorities", "CertificateAuthority", prefix="ACMPCA" # ), ], - "alexaforbusiness": [], # TODO: implement "amp": [ # AwsResotoModel("list-workspaces", "workspaces", "WorkspaceSummary", prefix="Amp"), ], @@ -609,6 +614,15 @@ def sample(shape: Shape) -> JsonElement: # prop_prefix="capacity_provider_", # ) ], + "efs": [ + # AwsResotoModel( + # "describe-file-systems", "FileSystems", "FileSystemDescription", prefix="Efs", name="EfsFileSystem" + # ), + # AwsResotoModel("describe-mount-targets", "MountTargets", "MountTargetDescription", prefix="Efs"), + # AwsResotoModel( + # "describe-access-points", "AccessPoints", "AccessPointDescription", prefix="Efs", name="EfsAccessPoint" + # ), + ], "elasticbeanstalk": [ # AwsResotoModel( # "describe-applications", @@ -812,8 +826,11 @@ def sample(shape: Shape) -> JsonElement: # ), ], "s3": [ - # AwsResotoModel(# "list-buckets", "Buckets", "Bucket", prefix="S3", prop_prefix="s3_" - # ) + # AwsResotoModel("list-buckets", "Buckets", "Bucket", prefix="S3", prop_prefix="s3_"), + # AwsResotoModel( + # "get-bucket-encryption", "ServerSideEncryptionConfiguration", "GetBucketEncryptionOutput", prefix="S3" + # ), + # AwsResotoModel("get-public-access-block", "PublicAccessBlockConfiguration", prefix="S3"), ], "sagemaker": [ # AwsResotoModel( @@ -933,6 +950,9 @@ def sample(shape: Shape) -> JsonElement: if __name__ == "__main__": - # print(json.dumps(create_test_response("lambda", "get-function-url-config"), indent=2)) + """print some test data""" + # print(json.dumps(create_test_response("efs", "describe-access-points"), indent=2)) + + """print the class models""" for model in all_models(): print(model.to_class()) diff --git a/resotocore/resotocore/model/model.py b/resotocore/resotocore/model/model.py index c684cc6f9..0824eb912 100644 --- a/resotocore/resotocore/model/model.py +++ b/resotocore/resotocore/model/model.py @@ -718,7 +718,9 @@ def check_valid(self, obj: JsonElement, **kwargs: bool) -> ValidationResult: def coerce_if_required(self, value: JsonElement, **kwargs: bool) -> Optional[List[JsonElement]]: has_coerced = False - if isinstance(value, dict): + if value is None: + return None + elif isinstance(value, dict): return None elif not isinstance(value, list): # in case of simple type, we can make it an array diff --git a/resotocore/resotocore/query/template_expander.py b/resotocore/resotocore/query/template_expander.py index 89dad3080..8a720df02 100644 --- a/resotocore/resotocore/query/template_expander.py +++ b/resotocore/resotocore/query/template_expander.py @@ -102,7 +102,8 @@ class TemplateExpanderBase(TemplateExpander): async def parse_query( self, to_parse: str, on_section: Optional[str], *, omit_section_expansion: bool = False, **env: str ) -> Query: - expanded, _ = await self.expand(to_parse) + rendered = self.render(to_parse, env) if env else to_parse + expanded, _ = await self.expand(rendered) result = query_parser.parse_query(expanded, **env) return result if omit_section_expansion else result.on_section(on_section) @@ -188,6 +189,10 @@ def parens(result: Any) -> str: def from_now(result: str) -> str: return utc_str(utc() + duration(result)) + @staticmethod + def ago(result: str) -> str: + return utc_str(utc() - duration(result)) + # noinspection PyTypeChecker getter: PropertyGetter = functools.partial( @@ -197,6 +202,7 @@ def from_now(result: str) -> str: "with_index": VirtualFunctions.with_index, "parens": VirtualFunctions.parens, "from_now": VirtualFunctions.from_now, + "ago": VirtualFunctions.ago, }, ) diff --git a/resotocore/resotocore/report/__init__.py b/resotocore/resotocore/report/__init__.py index 709e4c11d..8af9b42fb 100644 --- a/resotocore/resotocore/report/__init__.py +++ b/resotocore/resotocore/report/__init__.py @@ -109,11 +109,10 @@ class CheckResult: node_id: str = field(init=False, default=uuid_str()) def to_node(self) -> Json: - node = to_js(self.check) - node["id"] = self.node_id - node["kind"] = "report_check_result" - node["type"] = "node" - return node + reported = to_js(self.check) + reported["passed"] = self.passed + reported["number_of_resources_failing"] = self.number_of_resources_failing + return dict(id=self.node_id, kind="report_check_result", type="node", reported=reported) @define diff --git a/resotocore/resotocore/report/inspector_service.py b/resotocore/resotocore/report/inspector_service.py index afabb4287..eec65b45e 100644 --- a/resotocore/resotocore/report/inspector_service.py +++ b/resotocore/resotocore/report/inspector_service.py @@ -206,8 +206,7 @@ async def __perform_check(self, graph: str, model: Model, inspection: ReportChec async def perform_search(search: str) -> int: # parse query - rendered_query = self.template_expander.render(search, env) - query = await self.template_expander.parse_query(rendered_query, on_section="reported") + query = await self.template_expander.parse_query(search, on_section="reported", **env) # add aggregation to only query for count query = evolve(query, aggregate=Aggregate([], [AggregateFunction("sum", 1, [], "count")])) async with await self.db_access.get_graph_db(graph).search_aggregation(QueryModel(query, model)) as ctx: @@ -225,6 +224,9 @@ async def perform_cmd(cmd: str) -> int: return await perform_search(resoto_search) elif resoto_cmd := inspection.detect.get("resoto_cmd"): return await perform_cmd(resoto_cmd) + elif inspection.detect.get("manual"): + # let's assume the manual check is successful + return 0 else: raise ValueError(f"Invalid inspection {inspection.id}: no resoto or resoto_cmd defined") @@ -249,12 +251,13 @@ async def validate_check_collection_config(self, json: Json) -> Optional[Json]: for check in ReportCheckCollectionConfig.from_config(ConfigEntity(ResotoReportCheck, json)): env = check.default_values or {} if search := check.detect.get("resoto"): - rendered_query = self.template_expander.render(search, env) - await self.template_expander.parse_query(rendered_query, on_section="reported") + await self.template_expander.parse_query(search, on_section="reported", **env) elif cmd := check.detect.get("resoto_cmd"): await self.cli.evaluate_cli_command(cmd, CLIContext(env=env)) + elif check.detect.get("manual"): + pass else: - errors.append(f"Check {check.id} neither has a resoto nor resoto_cmd defined") + errors.append(f"Check {check.id} neither has a resoto, resoto_cmd or manual defined") if errors: return {"error": f"Can not validate check collection: {errors}"} else: diff --git a/resotocore/resotocore/static/report/benchmark/aws/aws_cis_1.5.json b/resotocore/resotocore/static/report/benchmark/aws/aws_cis_1.5.json index 21a4a74e2..6943cfbf9 100644 --- a/resotocore/resotocore/static/report/benchmark/aws/aws_cis_1.5.json +++ b/resotocore/resotocore/static/report/benchmark/aws/aws_cis_1.5.json @@ -12,87 +12,121 @@ { "title": "1.1 Maintain current contact details", "description": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization.", - "checks": [] + "checks": [ + "aws_iam_account_maintain_current_contact_details" + ] }, { "title": "1.2 Ensure security contact information is registered", "description": "AWS provides customers with the option of specifying the contact information for account's security team. It is recommended that this information be provided.", - "checks": [] + "checks": [ + "aws_iam_account_security_contact_information_is_registered" + ] }, { "title": "1.3 Ensure security questions are registered in the AWS account", "description": "The AWS support portal allows account owners to establish security questions that can be used to authenticate individuals calling AWS customer service for support. It is recommended that security questions be established.", - "checks": [] + "checks": [ + "aws_iam_account_security_questions_are_registered_in_the_aws_account" + ] }, { "title": "1.4 Ensure no 'root' user account access key exists", "description": "The 'root' user account is the most privileged user in an AWS account. AWS Access Keys provide programmatic access to a given AWS account. It is recommended that all access keys associated with the 'root' user account be removed.", - "checks": [] + "checks": [ + "aws_iam_no_root_access_key" + ] }, { "title": "1.5 Ensure MFA is enabled for the 'root' user account", "description": "The 'root' user account is the most privileged user in an AWS account. Multi-factor Authentication (MFA) adds an extra layer of protection on top of a username and password. With MFA enabled, when a user signs in to an AWS website, they will be prompted for their username and password as well as for an authentication code from their AWS MFA device.", - "checks": [] + "checks": [ + "aws_iam_root_mfa_enabled" + ] }, { "title": "1.6 Ensure hardware MFA is enabled for the 'root' user account", "description": "The 'root' user account is the most privileged user in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled, when a user signs in to an AWS website, they will be prompted for their user name and password as well as for an authentication code from their AWS MFA device. For Level 2, it is recommended that the root user account be protected with a hardware MFA.", - "checks": [] + "checks": [ + "aws_iam_root_hardware_mfa_enabled" + ] }, { "title": "1.7 Eliminate use of the 'root' user for administrative and daily tasks", "description": "With the creation of an AWS account, a 'root user' is created that cannot be disabled or deleted. That user has unrestricted access to and control over all resources in the AWS account. It is highly recommended that the use of this account be avoided for everyday tasks.", - "checks": [] + "checks": [ + "aws_iam_avoid_root_usage" + ] }, { "title": "1.8 Ensure IAM password policy requires minimum length of 14 or greater", "description": "Password policies are, in part, used to enforce password complexity requirements. IAM password policies can be used to ensure password are at least a given length. It is recommended that the password policy require a minimum password length 14.", - "checks": [] + "checks": [ + "aws_iam_password_policy_minimum_length_14" + ] }, { "title": "1.9 Ensure IAM password policy prevents password reuse", "description": "IAM password policies can prevent the reuse of a given password by the same user. It is recommended that the password policy prevent the reuse of passwords.", - "checks": [] + "checks": [ + "aws_iam_password_policy_reuse_24" + ] }, { "title": "1.10 Ensure multi-factor authentication (MFA) is enabled for all IAM users that have a console password", "description": "Multi-Factor Authentication (MFA) adds an extra layer of authentication assurance beyond traditional credentials. With MFA enabled, when a user signs in to the AWS Console, they will be prompted for their user name and password as well as for an authentication code from their physical or virtual MFA token. It is recommended that MFA be enabled for all accounts that have a console password.", - "checks": [] + "checks": [ + "aws_iam_user_mfa_enabled_console_access" + ] }, { "title": "1.11 Do not setup access keys during initial user setup for all IAM users that have a console password", "description": "AWS console defaults to no check boxes selected when creating a new IAM user. When creating the IAM User credentials you have to determine what type of access they require.", - "checks": [] + "checks": [ + "aws_iam_user_uses_access_keys_console_access" + ] }, { "title": "1.12 Ensure credentials unused for 45 days or greater are disabled", "description": "AWS IAM users can access AWS resources using different types of credentials, such as passwords or access keys. It is recommended that all credentials that have been unused in 45 or greater days be deactivated or removed.", - "checks": [] + "checks": [ + "aws_iam_disable_old_credentials" + ] }, { "title": "1.13 Ensure there is only one active access key available for any single IAM user", "description": "Access keys are long-term credentials for an IAM user or the AWS account root user. You can use access keys to sign programmatic requests to the AWS CLI or AWS API (directly or using the AWS SDK).", - "checks": [] + "checks": [ + "aws_iam_user_has_two_active_access_keys" + ] }, { "title": "1.14 Ensure access keys are rotated every 90 days or less", "description": "Access keys consist of an access key ID and secret access key, which are used to sign programmatic requests that you make to AWS. AWS users need their own access keys to make programmatic calls to AWS from the AWS Command Line Interface (AWS CLI), Tools for Windows PowerShell, the AWS SDKs, or direct HTTP calls using the APIs for individual AWS services. It is recommended that all access keys be regularly rotated.", - "checks": [] + "checks": [ + "aws_iam_rotate_access_keys_after_90_days" + ] }, { "title": "1.15 Ensure IAM Users Receive Permissions Only Through Groups", "description": "IAM users are granted access to services, functions, and data through IAM policies. There are three ways to define policies for a user: 1) Edit the user policy directly, aka an inline, or user, policy; 2) attach a policy directly to a user; 3) add the user to an IAM group that has an attached policy. Only the third implementation is recommended.", - "checks": [] + "checks": [ + "aws_iam_policy_attached_only_to_group_or_roles" + ] }, { "title": "1.16 Ensure IAM policies that allow full \"*:*\" administrative privileges are not attached", "description": "IAM policies are the means by which privileges are granted to users, groups, or roles. It is recommended and considered a standard security advice to grant least privilege -that is, granting only the permissions required to perform a task. Determine what users need to do and then craft policies for them that let the users perform only those tasks, instead of allowing full administrative privileges.", - "checks": [] + "checks": [ + "aws_iam_policy_with_administrative_privileges_not_in_use" + ] }, { "title": "1.17 Ensure a support role has been created to manage incidents with AWS Support", "description": "AWS provides a support center that can be used for incident notification and response, as well as technical support and customer services. Create an IAM Role to allow authorized users to manage incidents with AWS Support.", - "checks": [] + "checks": [ + "aws_iam_support_role_exists" + ] }, { "title": "1.18 Ensure IAM instance roles are used for AWS resource access from instances", @@ -104,17 +138,23 @@ { "title": "1.19 Ensure that all the expired SSL/TLS certificates stored in AWS IAM are removed", "description": "To enable HTTPS connections to your website or application in AWS, you need an SSL/TLS server certificate. You can use ACM or IAM to store and deploy server certificates. Use IAM as a certificate manager only when you must support HTTPS connections in a region that is not supported by ACM. IAM securely encrypts your private keys and stores the encrypted version in IAM SSL certificate storage. IAM supports deploying server certificates in all regions, but you must obtain your certificate from an external provider for use with AWS. You cannot upload an ACM certificate to IAM. Additionally, you cannot manage your certificates from the IAM Console.", - "checks": [] + "checks": [ + "aws_iam_expired_server_certificates" + ] }, { "title": "1.20 Ensure that IAM Access analyzer is enabled for all regions", "description": "Enable IAM Access analyzer for IAM policies about all resources in each region. IAM Access Analyzer is a technology introduced at AWS reinvent 2019. After the Analyzer is enabled in IAM, scan results are displayed on the console showing the accessible resources. Scans show resources that other accounts and federated users can access, such as KMS keys and IAM roles. So the results allow you to determine if an unintended user is allowed, making it easier for administrators to monitor least privileges access. Access Analyzer analyzes only policies that are applied to resources in the same AWS Region.", - "checks": [] + "checks": [ + "aws_iam_access_analyzer_enabled" + ] }, { "title": "1.21 Ensure IAM users are managed centrally via identity federation or AWS Organizations for multi-account environments", "description": "In multi-account environments, IAM user centralization facilitates greater user control. User access beyond the initial account is then provide via role assumption. Centralization of users can be accomplished through federation with an external identity provider or through the use of AWS Organizations.", - "checks": [] + "checks": [ + "aws_iam_check_saml_providers_sts" + ] } ] }, @@ -129,27 +169,37 @@ { "title": "2.1.1 Ensure all S3 buckets employ encryption-at-rest", "description": "Amazon S3 provides a variety of no, or low, cost encryption options to protect data at rest.", - "checks": [] + "checks": [ + "aws_ec2_s3_bucket_default_encryption" + ] }, { "title": "2.1.2 Ensure S3 Bucket Policy is set to deny HTTP requests", "description": "At the Amazon S3 bucket level, you can configure permissions through a bucket policy making the objects accessible only through HTTPS.", - "checks": [] + "checks": [ + "aws_ec2_s3_bucket_secure_transport_policy" + ] }, { "title": "2.1.3 Ensure MFA Delete is enabled on S3 buckets", "description": "Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.", - "checks": [] + "checks": [ + "aws_ec2_s3_bucket_no_mfa_delete" + ] }, { "title": "2.1.4 Ensure all data in Amazon S3 has been discovered, classified and secured when required", "description": "Amazon S3 buckets can contain sensitive data, that for security purposes should be discovered, monitored, classified and protected. Macie along with other 3rd party tools can automatically provide an inventory of Amazon S3 buckets.", - "checks": [] + "checks": [ + "aws_ec2_macie_is_enabled" + ] }, { "title": "2.1.5 Ensure that S3 Buckets are configured with 'Block public access (bucket settings)'", "description": "Amazon S3 provides Block public access (bucket settings) and Block public access (account settings) to help you manage public access to Amazon S3 resources. By default, S3 buckets and objects are created with public access disabled. However, an IAM principle with sufficient S3 permissions can enable public access at the bucket and/or object level. While enabled, Block public access (bucket settings) prevents an individual bucket, and its contained objects, from becoming publicly accessible. Similarly, Block public access (account settings) prevents all buckets, and contained objects, from becoming publicly accessible across the entire account.", - "checks": [] + "checks": [ + "aws_ec2_s3_account_level_public_access_blocks" + ] } ] }, @@ -160,7 +210,9 @@ { "title": "2.2.1 Ensure EBS Volume Encryption is Enabled in all Regions", "description": "Elastic Compute Cloud (EC2) supports encryption at rest when using the Elastic Block Store (EBS) service. While disabled by default, forcing encryption at EBS volume creation is supported.", - "checks": [] + "checks": [ + "aws_ec2_volume_not_encrypted" + ] } ] }, @@ -171,17 +223,23 @@ { "title": "2.3.1 Ensure that encryption is enabled for RDS Instances", "description": "Amazon RDS encrypted DB instances use the industry standard AES-256 encryption algorithm to encrypt your data on the server that hosts your Amazon RDS DB instances. After your data is encrypted, Amazon RDS handles authentication of access and decryption of your data transparently with a minimal impact on performance.", - "checks": [] + "checks": [ + "aws_rds_storage_encrypted" + ] }, { "title": "2.3.2 Ensure Auto Minor Version Upgrade feature is Enabled for RDS Instances", "description": "Ensure that RDS database instances have the Auto Minor Version Upgrade flag enabled in order to receive automatically minor engine upgrades during the specified maintenance window. So, RDS instances can get the new features, bug fixes, and security patches for their database engines.", - "checks": [] + "checks": [ + "aws_rds_auto_minor_version_upgrade" + ] }, { "title": "2.3.3 Ensure that public access is not given to RDS Instance", "description": "Ensure and verify that RDS database instances provisioned in your AWS account do restrict unauthorized access in order to minimize security risks. To restrict access to any publicly accessible RDS database instance, you must disable the database Publicly Accessible flag and update the VPC security group associated with the instance.", - "checks": [] + "checks": [ + "aws_rds_no_public_access" + ] } ] }, @@ -192,7 +250,9 @@ { "title": "2.4.1 Ensure that encryption is enabled for EFS file systems", "description": "EFS data should be encrypted at rest using AWS KMS (Key Management Service).", - "checks": [] + "checks": [ + "aws_efs_storage_encrypted" + ] } ] } diff --git a/resotocore/resotocore/static/report/checks/aws/aws_ec2.json b/resotocore/resotocore/static/report/checks/aws/aws_ec2.json index a2a696485..0405cc134 100644 --- a/resotocore/resotocore/static/report/checks/aws/aws_ec2.json +++ b/resotocore/resotocore/static/report/checks/aws/aws_ec2.json @@ -77,7 +77,7 @@ }, "remediation": { "text": "Create an IAM instance role if necessary and attach it to the corresponding EC2 instance..", - "url": "http://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html" + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html" } }, { @@ -574,6 +574,111 @@ "url": "https://docs.aws.amazon.com/vpc/latest/peering/peering-configurations-partial-access.html" }, "internal_notes": "Load peering connections and merge vpc and route tables. Then check if any route table cidr is 0.0.0.0/0 or the same as requester cidr or accepter cidr." + }, + { + "name": "s3_bucket_default_encryption", + "title": "Check if S3 buckets have default encryption (SSE) enabled or use a bucket policy to enforce it.", + "result_kind": "aws_s3_bucket", + "categories": [ "security", "compliance" ], + "risk": "Amazon S3 default encryption provides a way to set the default encryption behavior for an S3 bucket. This will ensure data-at-rest is encrypted.", + "severity": "medium", + "detect": { + "resoto": "is(aws_s3_bucket) and not bucket_encryption_rules[*].sse_algorithm!=null" + }, + "remediation": { + "action":{ + "aws_cli": "aws s3api put-bucket-encryption --bucket {{name}} --server-side-encryption-configuration '{'Rules': [{'ApplyServerSideEncryptionByDefault': {'SSEAlgorithm': 'AES256'}}]}'" + }, + "text": "Ensure that S3 buckets has encryption at rest enabled.", + "url": "https://aws.amazon.com/blogs/security/how-to-prevent-uploads-of-unencrypted-objects-to-amazon-s3/" + }, + "internal_notes": "" + }, + { + "name": "s3_bucket_no_mfa_delete", + "title": "Check if S3 bucket MFA Delete is not enabled.", + "result_kind": "aws_s3_bucket", + "categories": [ "security", "compliance" ], + "risk": "Your security credentials are compromised or unauthorized access is granted.", + "severity": "medium", + "detect": { + "resoto": "is(aws_s3_bucket) and bucket_mfa_delete=false" + }, + "remediation": { + "action": { + "aws_cli": "aws s3api put-bucket-versioning --bucket {{name}} --versioning-configuration MFADelete=Enabled --mfa 'arn:aws:iam::00000000:mfa/root-account-mfa-device 123456'" + }, + "text": "Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.", + "url": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiFactorAuthenticationDelete.html" + }, + "internal_notes": "" + }, + { + "name": "s3_bucket_secure_transport_policy", + "title": "Check if S3 buckets have secure transport policy.", + "result_kind": "aws_s3_bucket", + "categories": [ "security", "compliance" ], + "risk": "If HTTPS is not enforced on the bucket policy, communication between clients and S3 buckets can use unencrypted HTTP. As a result, sensitive information could be transmitted in clear text over the network or internet.", + "severity": "medium", + "detect": { + "resoto": "is(aws_s3_bucket) and not bucket_policy.Statement[*].{Effect=Deny and (Action=s3:PutObject or Action=\"s3:*\" or Action=\"*\") and Condition.Bool.`aws:SecureTransport`== \"false\" }" + }, + "remediation": { + "text": "Ensure that S3 buckets has encryption in transit enabled.", + "url": "https://aws.amazon.com/premiumsupport/knowledge-center/s3-bucket-policy-for-config-rule/" + }, + "internal_notes": "" + }, + { + "name": "macie_is_enabled", + "title": "Check if Amazon Macie is enabled.", + "result_kind": "aws_s3_bucket", + "categories": [ "security", "compliance" ], + "risk": "Amazon Macie is a fully managed data security and data privacy service that uses machine learning and pattern matching to help you discover, monitor and protect your sensitive data in AWS.", + "severity": "medium", + "detect": { + "manual": "Check if Amazon Macie is enabled." + }, + "remediation": { + "text": "Enable Amazon Macie and create appropriate jobs to discover sensitive data.", + "url": "https://aws.amazon.com/macie/getting-started/" + }, + "internal_notes": "" + }, + { + "name": "s3_account_level_public_access_blocks", + "title": "Check S3 Account Level Public Access Block.", + "result_kind": "aws_s3_bucket", + "categories": [ "security", "compliance" ], + "risk": "Public access policies may be applied to sensitive data buckets.", + "severity": "high", + "detect": { + "resoto": "is(aws_s3_bucket) {account_setting: <-[0:]- is(aws_account) --> is(aws_s3_account_settings)} (bucket_public_access_block_configuration.block_public_acls==false and account_setting.reported.bucket_public_access_block_configuration.block_public_acls==false) or (bucket_public_access_block_configuration.ignore_public_acls==false and account_setting.reported.bucket_public_access_block_configuration.ignore_public_acls==false) or (bucket_public_access_block_configuration.block_public_policy==false and account_setting.reported.bucket_public_access_block_configuration.block_public_policy==false) or (bucket_public_access_block_configuration.restrict_public_buckets==false and account_setting.reported.bucket_public_access_block_configuration.restrict_public_buckets==false)" + }, + "remediation": { + "action": { + "aws_cli": "aws s3control put-public-access-block --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true --account-id {{account_id}}" + }, + "text": "You can enable Public Access Block at the account level to prevent the exposure of your data stored in S3.", + "url": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html" + }, + "internal_notes": "" + }, + { + "name": "volume_not_encrypted", + "title": "Ensure there are no EBS Volumes unencrypted.", + "result_kind": "aws_ec2_volume", + "categories": [ "security", "compliance" ], + "risk": "Data encryption at rest prevents data visibility in the event of its unauthorized access or theft.", + "severity": "medium", + "detect": { + "resoto": "is(aws_ec2_volume) and volume_encrypted=false" + }, + "remediation": { + "text": "Encrypt all EBS volumes and Enable Encryption by default You can configure your AWS account to enforce the encryption of the new EBS volumes and snapshot copies that you create. For example; Amazon EBS encrypts the EBS volumes created when you launch an instance and the snapshots that you copy from an unencrypted snapshot.", + "url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html" + }, + "internal_notes": "" } ] } diff --git a/resotocore/resotocore/static/report/checks/aws/aws_efs.json b/resotocore/resotocore/static/report/checks/aws/aws_efs.json new file mode 100644 index 000000000..46d4f1c72 --- /dev/null +++ b/resotocore/resotocore/static/report/checks/aws/aws_efs.json @@ -0,0 +1,21 @@ +{ + "provider": "aws", + "service": "efs", + "checks": [ + { + "name": "storage_encrypted", + "title": "Check if EFS protects sensitive data with encryption at rest", + "result_kind": "aws_efs_file_system", + "categories": ["security", "compliance"], + "risk": "EFS should be encrypted at rest to prevent exposure of sensitive data to bad actors", + "severity": "medium", + "detect": { + "resoto": "is(aws_efs_file_system) and volume_encrypted==false" + }, + "remediation": { + "text": "Ensure that encryption at rest is enabled for EFS file systems. Encryption at rest can only be enabled during the file system creation.", + "url": "https://docs.aws.amazon.com/efs/latest/ug/encryption-at-rest.html" + } + } + ] +} diff --git a/resotocore/resotocore/static/report/checks/aws/aws_iam.json b/resotocore/resotocore/static/report/checks/aws/aws_iam.json new file mode 100644 index 000000000..c421affb7 --- /dev/null +++ b/resotocore/resotocore/static/report/checks/aws/aws_iam.json @@ -0,0 +1,323 @@ +{ + "provider": "aws", + "service": "iam", + "checks": [ + { + "name": "account_maintain_current_contact_details", + "title": "Maintain current contact details.", + "result_kind": "aws_account", + "categories": [ "security", "compliance" ], + "risk": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details; and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner; AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation; proactive measures may be taken; including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.", + "severity": "medium", + "detect": { + "manual": "Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Contact Information." + }, + "remediation": { + "text": "Using the Billing and Cost Management console complete contact details.", + "url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html" + } + }, + { + "name": "account_security_contact_information_is_registered", + "title": "Ensure security contact information is registered", + "result_kind": "aws_account", + "categories": [ "security", "compliance" ], + "risk": "AWS provides customers with the option of specifying the contact information for accounts security team. It is recommended that this information be provided. Specifying security-specific contact information will help ensure that security advisories sent by AWS reach the team in your organization that is best equipped to respond to them.", + "severity": "medium", + "detect": { + "manual": "Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Alternate Contacts -> Security Section." + }, + "remediation": { + "text": "Go to the My Account section and complete alternate contacts.", + "url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html" + } + }, + { + "name": "account_security_questions_are_registered_in_the_aws_account", + "title": "Ensure security questions are registered in the AWS account.", + "result_kind": "aws_account", + "categories": [ "security", "compliance" ], + "risk": "The AWS support portal allows account owners to establish security questions that can be used to authenticate individuals calling AWS customer service for support. It is recommended that security questions be established. When creating a new AWS account a default super user is automatically created. This account is referred to as the root account. It is recommended that the use of this account be limited and highly controlled. During events in which the root password is no longer accessible or the MFA token associated with root is lost", + "severity": "medium", + "detect": { + "manual": "Login to the AWS Console as root. Choose your account name on the top right of the window -> My Account -> Configure Security Challenge Questions." + }, + "remediation": { + "text": "Login as root account and from My Account configure Security questions.", + "url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-security-challenge.html" + } + }, + { + "name": "no_root_access_key", + "title": "Ensure no root account access key exists", + "result_kind": "aws_root_user", + "categories": [ "security", "compliance" ], + "risk": "The root account is the most privileged user in an AWS account. AWS Access Keys provide programmatic access to a given AWS account. It is recommended that all access keys associated with the root account be removed. Removing access keys associated with the root account limits vectors by which the account can be compromised. Removing the root access keys encourages the creation and use of role based accounts that are least privileged.", + "severity": "critical", + "detect": { + "resoto": "is(aws_root_user) with(any, --> is(access_key))" + }, + "remediation": { + "text": "Use the credential report to that the user and ensure the access_key_1_active and access_key_2_active fields are set to FALSE.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html" + } + }, + { + "name": "root_mfa_enabled", + "title": "Ensure MFA is enabled for the root account", + "result_kind": "aws_root_user", + "categories": [ "security", "compliance" ], + "risk": "The root account is the most privileged user in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled when a user signs in to an AWS website they will be prompted for their user name and password as well as for an authentication code from their AWS MFA device. When virtual MFA is used for root accounts it is recommended that the device used is NOT a personal device but rather a dedicated mobile device (tablet or phone) that is managed to be kept charged and secured independent of any individual personal devices. (non-personal virtual MFA) This lessens the risks of losing access to the MFA due to device loss / trade-in or if the individual owning the device is no longer employed at the company.", + "severity": "critical", + "detect": { + "resoto": "is(aws_root_user) and mfa_active!=true" + }, + "remediation": { + "text": "Using IAM console navigate to Dashboard and expand Activate MFA on your root account.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html#id_root-user_manage_mfa" + } + }, + { + "name": "root_hardware_mfa_enabled", + "title": "Ensure hardware MFA is enabled for the root account", + "result_kind": "aws_root_user", + "categories": [ "security", "compliance" ], + "risk": "The root account is the most privileged user in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled when a user signs in to an AWS website they will be prompted for their user name and password as well as for an authentication code from their AWS MFA device. For Level 2 it is recommended that the root account be protected with a hardware MFA./ trade-in or if the individual owning the device is no longer employed at the company.", + "severity": "critical", + "detect": { + "resoto": "is(aws_root_user) and user_virtual_mfa_devices!=null and user_virtual_mfa_devices!=[]" + }, + "remediation": { + "text": "Using IAM console navigate to Dashboard and expand Activate MFA on your root account.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html#id_root-user_manage_mfa" + } + }, + { + "name": "avoid_root_usage", + "title": "Avoid the use of the root accounts", + "result_kind": "aws_root_user", + "categories": [ "security", "compliance" ], + "risk": "The root account has unrestricted access to all resources in the AWS account. It is highly recommended that the use of this account be avoided.", + "severity": "critical", + "detect": { + "resoto": "is(aws_root_user) {access_keys[]: --> is(access_key)} password_last_used>{{last_access_younger_than.ago}} or access_keys[*].reported.access_key_last_used.last_used>{{last_access_younger_than.ago}}" + }, + "default_values": { + "last_access_younger_than": "1d" + }, + "remediation": { + "text": "Follow the remediation instructions of the Ensure IAM policies are attached only to groups or roles recommendation.", + "url": "http://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html" + } + }, + { + "name": "password_policy_minimum_length_14", + "title": "Ensure IAM password policy requires minimum length of 14 or greater", + "result_kind": "aws_account", + "categories": [ "security", "compliance" ], + "risk": "Password policies are used to enforce password complexity requirements. IAM password policies can be used to ensure password are comprised of different character sets. It is recommended that the password policy require minimum length of 14 or greater.", + "severity": "medium", + "detect": { + "resoto": "is(aws_account) and minimum_password_length<14" + }, + "remediation": { + "text": "Ensure \"Minimum password length\" is checked under \"Password Policy\".", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html" + } + }, + { + "name": "password_policy_reuse_24", + "title": "Ensure IAM password policy prevents password reuse: 24 or greater", + "result_kind": "aws_account", + "categories": [ "security", "compliance" ], + "risk": "Password policies are used to enforce password complexity requirements. IAM password policies can be used to ensure password are comprised of different character sets. It is recommended that the password policy prevents at least password reuse of 24 or greater.", + "severity": "medium", + "detect": { + "resoto": "is(aws_account) and password_reuse_prevention<24" + }, + "remediation": { + "text": "Ensure \"Number of passwords to remember\" is set to 24.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html" + } + }, + { + "name": "user_mfa_enabled_console_access", + "title": "Ensure multi-factor authentication (MFA) is enabled for all IAM users that have a console password.", + "result_kind": "aws_iam_user", + "categories": [ "security", "compliance" ], + "risk": "Password policies are used to enforce password complexity requirements. IAM password policies can be used to ensure password are comprised of different character sets. It is recommended that the password policy prevents at least password reuse of 24 or greater.", + "severity": "high", + "detect": { + "resoto": "is(aws_iam_user) and password_enabled==true and mfa_active==false" + }, + "remediation": { + "text": "Enable MFA for users account. MFA is a simple best practice that adds an extra layer of protection on top of your user name and password. Recommended to use hardware keys over virtual MFA.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html" + } + }, + { + "name": "user_uses_access_keys_console_access", + "title": "Do not setup access keys during initial user setup for all IAM users that have a console password", + "result_kind": "aws_iam_access_key", + "categories": [ "security", "compliance" ], + "risk": "AWS console defaults the checkbox for creating access keys to enabled. This results in many access keys being generated unnecessarily. In addition to unnecessary credentials; it also generates unnecessary management work in auditing and rotating these keys. Requiring that additional steps be taken by the user after their profile has been created will give a stronger indication of intent that access keys are (a) necessary for their work and (b) once the access key is established on an account that the keys may be in use somewhere in the organization.", + "severity": "medium", + "detect": { + "resoto": "is(aws_iam_access_key) and access_key_status==\"Active\" and access_key_last_used.last_used==null and /ancestors.aws_iam_user.reported.password_enabled==true" + }, + "remediation": { + "text": "From the IAM console: generate credential report and disable not required keys.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html" + } + }, + { + "name": "disable_old_credentials", + "title": "Ensure credentials unused for 45 days or greater are disabled", + "result_kind": "aws_iam_access_key", + "categories": [ "security", "compliance" ], + "risk": "To increase the security of your AWS account; remove IAM user credentials (that is; passwords and access keys) that are not needed. For example; when users leave your organization or no longer need AWS access.", + "severity": "medium", + "detect": { + "resoto": "is(aws_iam_user) and password_last_used<{{password_used_since.ago}}" + }, + "default_values": { + "password_used_since": "45d" + }, + "remediation": { + "text": "From the IAM console: generate credential report and disable not required keys.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html" + } + }, + { + "name": "user_has_two_active_access_keys", + "title": "Check if IAM users have two active access keys", + "result_kind": "aws_iam_user", + "categories": [ "security", "compliance" ], + "risk": "Access Keys could be lost or stolen. It creates a critical risk.", + "severity": "medium", + "detect": { + "resoto": "is(aws_iam_user) {access_keys[]: --> is(access_key)} access_keys[0].reported.access_key_status==\"Active\" and access_keys[1].reported.access_key_status==\"Active\"" + }, + "remediation": { + "text": "Avoid using long lived access keys.", + "url": "https://docs.aws.amazon.com/IAM/latest/APIReference/API_ListAccessKeys.html" + } + }, + { + "name": "rotate_access_keys_after_90_days", + "title": "Ensure access keys are rotated every 90 days or less", + "result_kind": "aws_iam_access_key", + "categories": [ "security", "compliance" ], + "risk": "Access keys consist of an access key ID and secret access key which are used to sign programmatic requests that you make to AWS. AWS users need their own access keys to make programmatic calls to AWS from the AWS Command Line Interface (AWS CLI)- Tools for Windows PowerShell- the AWS SDKs- or direct HTTP calls using the APIs for individual AWS services. It is recommended that all access keys be regularly rotated.", + "severity": "medium", + "detect": { + "resoto": "is(aws_iam_access_key) and access_key_last_used.last_rotated<{{last_rotated_max.ago}}" + }, + "default_values": { + "last_rotated_max": "90d" + }, + "remediation": { + "text": "Use the credential report to ensure access_key_X_last_rotated is less than 90 days ago.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html" + } + }, + { + "name": "policy_attached_only_to_group_or_roles", + "title": "Ensure IAM policies are attached only to groups or roles", + "result_kind": "aws_iam_access_key", + "categories": [ "security", "compliance" ], + "risk": "By default IAM users; groups; and roles have no access to AWS resources. IAM policies are the means by which privileges are granted to users; groups; or roles. It is recommended that IAM policies be applied directly to groups and roles but not users. Assigning privileges at the group or role level reduces the complexity of access management as the number of users grow. Reducing access management complexity may in-turn reduce opportunity for a principal to inadvertently receive or retain excessive privileges.", + "severity": "low", + "detect": { + "resoto": "is(aws_iam_user) {attached_policy: --> is(aws_iam_policy)} user_policies!=[] or attached_policy!=null" + }, + "remediation": { + "text": "Remove any policy attached directly to the user. Use groups or roles instead.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html" + } + }, + { + "name": "policy_with_administrative_privileges_not_in_use", + "title": "Ensure IAM policies that allow full \"*:*\" administrative privileges are not in use.", + "result_kind": "aws_iam_policy", + "categories": [ "security", "compliance" ], + "risk": "IAM policies are the means by which privileges are granted to users; groups; or roles. It is recommended and considered a standard security advice to grant least privilege—that is; granting only the permissions required to perform a task. Determine what users need to do and then craft policies for them that let the users perform only those tasks instead of allowing full administrative privileges. Providing full administrative privileges instead of restricting to the minimum set of permissions that the user is required to do exposes the resources to potentially unwanted actions.", + "severity": "medium", + "detect": { + "resoto": "is(aws_iam_policy) and policy_document.document.Statement[*].{Effect=Allow and (Action=\"*\" and Resource=\"*\")} and policy_attachment_count>0" + }, + "remediation": { + "text": "It is more secure to start with a minimum set of permissions and grant additional permissions as necessary; rather than starting with permissions that are too lenient and then trying to tighten them later. List policies an analyze if permissions are the least possible to conduct business activities.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html" + } + }, + { + "name": "support_role_exists", + "title": "Ensure a support role has been created to manage incidents with AWS Support", + "result_kind": "aws_account", + "categories": [ "security", "compliance" ], + "risk": "AWS provides a support center that can be used for incident notification and response; as well as technical support and customer services. Create an IAM Role to allow authorized users to manage incidents with AWS Support.", + "severity": "medium", + "detect": { + "resoto": "is(aws_account) with(empty, -[0:2]-> is(aws_iam_role) and name=AWSServiceRoleForSupport and role_assume_role_policy_document.Statement[*].{Effect=Allow and Principal.Service=support.amazonaws.com and Action=\"sts:AssumeRole\"})" + }, + "remediation": { + "text": "Create an IAM role for managing incidents with AWS.", + "url": "https://docs.aws.amazon.com/awssupport/latest/user/using-service-linked-roles-sup.html" + } + }, + { + "name": "expired_server_certificates", + "title": "Ensure that all the expired SSL/TLS certificates stored in AWS IAM are removed.", + "result_kind": "aws_iam_server_certificate", + "categories": [ "security", "compliance" ], + "risk": "Removing expired SSL/TLS certificates eliminates the risk that an invalid certificate will be deployed accidentally to a resource such as AWS Elastic Load Balancer (ELB), which can damage the credibility of the application/website behind the ELB.", + "severity": "critical", + "detect": { + "resoto": "is(aws_iam_server_certificate) and expires<{{certificate_expiration.from_now}}" + }, + "default_values": { + "certificate_expiration": "0d" + }, + "remediation": { + "action":{ + "cli": "search is(aws_iam_server_certificate) and expires<@UTC@ | clean", + "aws_cli": "aws iam delete-server-certificate --server-certificate-name {{name}}" + }, + + "text": "Deleting the certificate could have implications for your application if you are using an expired server certificate with Elastic Load Balancing, CloudFront, etc. One has to make configurations at respective services to ensure there is no interruption in application functionality.", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_server-certs.html" + } + }, + { + "name": "access_analyzer_enabled", + "title": "Check if IAM Access Analyzer is enabled.", + "result_kind": "aws_account", + "categories": [ "security", "compliance" ], + "risk": "AWS IAM Access Analyzer helps you identify the resources in your organization and accounts, such as Amazon S3 buckets or IAM roles, that are shared with an external entity. This lets you identify unintended access to your resources and data, which is a security risk. IAM Access Analyzer uses a form of mathematical analysis called automated reasoning, which applies logic and mathematical inference to determine all possible access paths allowed by a resource policy.", + "severity": "low", + "detect": { + "manual": "Check that IAM Access Analyzer is enabled and that no analyzer produced any findings. `aws accessanalyzer list-analyzers` and `aws accessanalyzer list-findings`" + }, + "remediation": { + "text": "Enable IAM Access Analyzer for all accounts, create analyzer and take action over it is recommendations (IAM Access Analyzer is available at no additional cost).", + "url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html" + } + }, + { + "name": "check_saml_providers_sts", + "title": "Check if there are SAML Providers that can be used for STS", + "result_kind": "aws_account", + "categories": [ "security", "compliance" ], + "risk": "Without SAML provider users with AWS CLI or AWS API access can use IAM static credentials. SAML helps users to assume role by default each time they authenticate.", + "severity": "low", + "detect": { + "manual": "Check that saml providers are available: `aws iam list-saml-providers`" + }, + "remediation": { + "text": "Enable SAML provider and use temporary credentials. You can use temporary security credentials to make programmatic requests for AWS resources using the AWS CLI or AWS API (using the AWS SDKs ). The temporary credentials provide the same permissions that you have with use long-term security credentials such as IAM user credentials. In case of not having SAML provider capabilities prevent usage of long-lived credentials.", + "url": "https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithSAML.html" + } + } + ] +} diff --git a/resotocore/resotocore/static/report/checks/aws/aws_rds.json b/resotocore/resotocore/static/report/checks/aws/aws_rds.json new file mode 100644 index 000000000..f574f254e --- /dev/null +++ b/resotocore/resotocore/static/report/checks/aws/aws_rds.json @@ -0,0 +1,51 @@ +{ + "provider": "aws", + "service": "rds", + "checks": [ + { + "name": "storage_encrypted", + "title": "Check if RDS instances storage is encrypted.", + "result_kind": "aws_rds_instance", + "categories": ["security", "compliance"], + "risk": "If not enabled sensitive information at rest is not protected.", + "severity": "medium", + "detect": { + "resoto": "is(aws_rds_instance) and volume_encrypted==false" + }, + "remediation": { + "text": "Enable Encryption.", + "url": "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html" + } + }, + { + "name": "auto_minor_version_upgrade", + "title": "Ensure RDS instances have minor version upgrade enabled.", + "result_kind": "aws_rds_instance", + "categories": ["security", "compliance"], + "risk": "Auto Minor Version Upgrade is a feature that you can enable to have your database automatically upgraded when a new minor database engine version is available. Minor version upgrades often patch security vulnerabilities and fix bugs and therefore should be applied.", + "severity": "low", + "detect": { + "resoto": "is(aws_rds_instance) and rds_auto_minor_version_upgrade==false" + }, + "remediation": { + "text": "Enable auto minor version upgrade for all databases and environments.", + "url": "https://aws.amazon.com/blogs/database/best-practices-for-upgrading-amazon-rds-to-major-and-minor-versions-of-postgresql" + } + }, + { + "name": "no_public_access", + "title": "Ensure there are no Public Accessible RDS instances.", + "result_kind": "aws_rds_instance", + "categories": ["security", "compliance"], + "risk": "Auto Minor Version Upgrade is a feature that you can enable to have your database automatically upgraded when a new minor database engine version is available. Minor version upgrades often patch security vulnerabilities and fix bugs and therefore should be applied.", + "severity": "critical", + "detect": { + "resoto": "is(aws_rds_instance) and db_publicly_accessible==true" + }, + "remediation": { + "text": "Do not allow public access.", + "url": "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_RDS_Configuring.html" + } + } + ] +} diff --git a/resotolib/resotolib/baseresources.py b/resotolib/resotolib/baseresources.py index b4d4d18bd..c16b2d602 100644 --- a/resotolib/resotolib/baseresources.py +++ b/resotolib/resotolib/baseresources.py @@ -588,6 +588,18 @@ class PhantomBaseResource(BaseResource): kind: ClassVar[str] = "phantom_resource" phantom: ClassVar[bool] = True + def update_tag(self, key, value) -> bool: + log.error(f"Resource {self.rtdname} is a phantom resource and does not maintain tags") + return False + + def delete_tag(self, key) -> bool: + log.error(f"Resource {self.rtdname} is a phantom resource and does not maintain tags") + return False + + def delete(self, graph) -> bool: + log.error(f"Resource {self.rtdname} is a phantom resource and can't be deleted") + return False + def cleanup(self, graph=None) -> bool: log.error(f"Resource {self.rtdname} is a phantom resource and can't be cleaned up") return False diff --git a/resotolib/resotolib/utils.py b/resotolib/resotolib/utils.py index 0aad1f03e..10e4261ab 100644 --- a/resotolib/resotolib/utils.py +++ b/resotolib/resotolib/utils.py @@ -44,6 +44,17 @@ def utc_str(dt: datetime = utc()) -> str: return dt.strftime(UTC_Date_Format) +def parse_utc(date_string: str) -> datetime: + dt = datetime.fromisoformat(date_string) + if ( + not dt.tzinfo + or dt.tzinfo.utcoffset(None) is None + or dt.tzinfo.utcoffset(None).total_seconds() != 0 # type: ignore + ): + dt = dt.astimezone(timezone.utc) + return dt + + def make_valid_timestamp(timestamp: datetime) -> Optional[datetime]: if not isinstance(timestamp, datetime) and isinstance(timestamp, date): timestamp = datetime.combine(timestamp, datetime.min.time()).replace(tzinfo=timezone.utc)