runbooks 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. runbooks/__init__.py +1 -1
  2. runbooks/aws/__init__.py +58 -0
  3. runbooks/aws/dynamodb_operations.py +231 -0
  4. runbooks/aws/ec2_copy_image_cross-region.py +195 -0
  5. runbooks/aws/ec2_describe_instances.py +202 -0
  6. runbooks/aws/ec2_ebs_snapshots_delete.py +186 -0
  7. runbooks/aws/ec2_run_instances.py +207 -0
  8. runbooks/aws/ec2_start_stop_instances.py +199 -0
  9. runbooks/aws/ec2_terminate_instances.py +143 -0
  10. runbooks/aws/ec2_unused_eips.py +196 -0
  11. runbooks/aws/ec2_unused_volumes.py +184 -0
  12. runbooks/aws/s3_create_bucket.py +140 -0
  13. runbooks/aws/s3_list_buckets.py +152 -0
  14. runbooks/aws/s3_list_objects.py +151 -0
  15. runbooks/aws/s3_object_operations.py +183 -0
  16. runbooks/aws/tagging_lambda_handler.py +172 -0
  17. runbooks/python101/calculator.py +34 -0
  18. runbooks/python101/config.py +1 -0
  19. runbooks/python101/exceptions.py +16 -0
  20. runbooks/python101/file_manager.py +218 -0
  21. runbooks/python101/toolkit.py +153 -0
  22. runbooks/security_baseline/__init__.py +0 -0
  23. runbooks/security_baseline/checklist/__init__.py +17 -0
  24. runbooks/security_baseline/checklist/account_level_bucket_public_access.py +86 -0
  25. runbooks/security_baseline/checklist/alternate_contacts.py +65 -0
  26. runbooks/security_baseline/checklist/bucket_public_access.py +82 -0
  27. runbooks/security_baseline/checklist/cloudwatch_alarm_configuration.py +66 -0
  28. runbooks/security_baseline/checklist/direct_attached_policy.py +69 -0
  29. runbooks/security_baseline/checklist/guardduty_enabled.py +71 -0
  30. runbooks/security_baseline/checklist/iam_password_policy.py +43 -0
  31. runbooks/security_baseline/checklist/iam_user_mfa.py +39 -0
  32. runbooks/security_baseline/checklist/multi_region_instance_usage.py +55 -0
  33. runbooks/security_baseline/checklist/multi_region_trail.py +64 -0
  34. runbooks/security_baseline/checklist/root_access_key.py +72 -0
  35. runbooks/security_baseline/checklist/root_mfa.py +39 -0
  36. runbooks/security_baseline/checklist/root_usage.py +128 -0
  37. runbooks/security_baseline/checklist/trail_enabled.py +68 -0
  38. runbooks/security_baseline/checklist/trusted_advisor.py +24 -0
  39. runbooks/security_baseline/report_generator.py +149 -0
  40. runbooks/security_baseline/run_script.py +76 -0
  41. runbooks/security_baseline/security_baseline_tester.py +179 -0
  42. runbooks/security_baseline/utils/__init__.py +1 -0
  43. runbooks/security_baseline/utils/common.py +109 -0
  44. runbooks/security_baseline/utils/enums.py +44 -0
  45. runbooks/security_baseline/utils/language.py +762 -0
  46. runbooks/security_baseline/utils/level_const.py +5 -0
  47. runbooks/security_baseline/utils/permission_list.py +26 -0
  48. runbooks/utils/__init__.py +0 -0
  49. runbooks/utils/logger.py +36 -0
  50. {runbooks-0.1.7.dist-info → runbooks-0.1.8.dist-info}/METADATA +2 -2
  51. runbooks-0.1.8.dist-info/RECORD +54 -0
  52. runbooks-0.1.7.dist-info/RECORD +0 -6
  53. {runbooks-0.1.7.dist-info → runbooks-0.1.8.dist-info}/WHEEL +0 -0
  54. {runbooks-0.1.7.dist-info → runbooks-0.1.8.dist-info}/entry_points.txt +0 -0
  55. {runbooks-0.1.7.dist-info → runbooks-0.1.8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,66 @@
1
+ import logging
2
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
+
4
+ import botocore.exceptions
5
+ from utils import common
6
+ from utils import level_const as level
7
+
8
+
9
+ def get_cloudwatch_alarms(client, region):
10
+ try:
11
+ alarms = client.describe_alarms()["MetricAlarms"]
12
+ return region, alarms
13
+ except (client.exceptions.InvalidNextToken, botocore.exceptions.ClientError) as e:
14
+ logging.error(f"Error getting CloudWatch alarms for region {region}: {str(e)}", exc_info=True)
15
+ return region, "ERR"
16
+
17
+
18
+ def check_cloudwatch_alarm_configuration(session, translator) -> common.CheckResult:
19
+ logging.info(translator.translate("checking"))
20
+
21
+ ret = common.CheckResult()
22
+ ret.title = translator.translate("title")
23
+ ret.result_cols = ["Region", "Name"]
24
+
25
+ ec2_client = session.client("ec2")
26
+
27
+ try:
28
+ regions = common.get_opted_in_regions(ec2_client)
29
+ except botocore.exceptions.ClientError as e:
30
+ logging.error(f"Error getting opted-in regions: {str(e)}", exc_info=True)
31
+ ret.level = level.error
32
+ ret.msg = translator.translate("unexpected_error")
33
+ ret.result_rows.append(["ERR", "ERR"])
34
+ ret.error_message = str(e)
35
+ return ret
36
+
37
+ with ThreadPoolExecutor() as thread_executor:
38
+ futures = [
39
+ thread_executor.submit(get_cloudwatch_alarms, session.client("cloudwatch", region_name=region), region)
40
+ for region in regions
41
+ ]
42
+
43
+ is_alarm_exist = False
44
+ ret.result_rows = []
45
+ error_occurred = False
46
+
47
+ for future in as_completed(futures):
48
+ region, alarms = future.result()
49
+ if alarms == "ERR":
50
+ error_occurred = True
51
+ ret.result_rows.append([region, "ERR"])
52
+ elif alarms:
53
+ is_alarm_exist = True
54
+ ret.result_rows.extend([region, alarm["AlarmName"]] for alarm in alarms)
55
+
56
+ if error_occurred:
57
+ ret.level = level.error
58
+ ret.msg = translator.translate("retrieval_error")
59
+ elif is_alarm_exist:
60
+ ret.level = level.success
61
+ ret.msg = translator.translate("alarm_exist")
62
+ else:
63
+ ret.level = level.warning
64
+ ret.msg = translator.translate("alarm_not_exist")
65
+
66
+ return ret
@@ -0,0 +1,69 @@
1
+ import logging
2
+
3
+ import botocore.exceptions
4
+ from utils import common
5
+ from utils import level_const as level
6
+
7
+
8
+ def get_user_policies(client, user_name):
9
+ try:
10
+ direct_attached = len(client.list_attached_user_policies(UserName=user_name)["AttachedPolicies"])
11
+ inline = len(client.list_user_policies(UserName=user_name)["PolicyNames"])
12
+ return str(direct_attached), str(inline)
13
+ except botocore.exceptions.ClientError as e:
14
+ logging.error(f"Error getting policies for user {user_name}: {str(e)}", exc_info=True)
15
+ return "ERR", "ERR"
16
+
17
+
18
+ def check_iam_direct_attached_policy(session, translator) -> common.CheckResult:
19
+ logging.info(translator.translate("checking"))
20
+
21
+ client = session.client("iam")
22
+ ret = common.CheckResult()
23
+ ret.title = translator.translate("title")
24
+ ret.result_cols = ["IAM User", "Direct Attached Managed Policy", "Inline Policy"]
25
+
26
+ try:
27
+ user_list = client.list_users()["Users"]
28
+ except client.exceptions.NoSuchEntityException:
29
+ ret.level = level.warning
30
+ ret.msg = translator.translate("no_user")
31
+ ret.result_rows.append(["-", "-", "-"])
32
+ return ret
33
+ except client.exceptions.ServiceFailureException as e:
34
+ ret.level = level.error
35
+ ret.msg = translator.translate("service_failure")
36
+ ret.result_rows.append(["ERR", "ERR", "ERR"])
37
+ ret.error_message = str(e)
38
+ logging.error(f"Service Failure: {str(e)}", exc_info=True)
39
+ return ret
40
+ except botocore.exceptions.ClientError as e:
41
+ ret.level = level.error
42
+ ret.msg = translator.translate("unexpected_error")
43
+ ret.result_rows.append(["ERR", "ERR", "ERR"])
44
+ ret.error_message = str(e)
45
+ logging.error(f"Unexpected Error: {str(e)}", exc_info=True)
46
+ return ret
47
+
48
+ if not user_list:
49
+ ret.level = level.warning
50
+ ret.msg = translator.translate("no_user")
51
+ return ret
52
+
53
+ ret.level = level.success
54
+ ret.msg = translator.translate("success")
55
+
56
+ for user in user_list:
57
+ user_name = user["UserName"]
58
+ direct_attached, inline = get_user_policies(client, user_name)
59
+
60
+ if direct_attached == "ERR" or inline == "ERR":
61
+ ret.level = level.error
62
+ ret.msg = translator.translate("unexpected_error")
63
+ else:
64
+ ret.result_rows.append([user_name, direct_attached, inline])
65
+ if int(direct_attached) > 0 or int(inline) > 0:
66
+ ret.level = level.warning
67
+ ret.msg = translator.translate("warning")
68
+
69
+ return ret
@@ -0,0 +1,71 @@
1
+ import logging
2
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
+
4
+ import botocore.exceptions
5
+ from utils import common
6
+ from utils import level_const as level
7
+
8
+
9
+ def get_guard_duty_configuration(client, region):
10
+ try:
11
+ detectors = client.list_detectors()["DetectorIds"]
12
+ return region, len(detectors)
13
+ except (
14
+ client.exceptions.BadRequestException,
15
+ client.exceptions.InternalServerErrorException,
16
+ botocore.exceptions.ClientError,
17
+ ) as e:
18
+ logging.error(f"Error getting GuardDuty configuration for region {region}: {str(e)}", exc_info=True)
19
+ return region, "ERR"
20
+
21
+
22
+ def check_guard_duty_enabled(session, translator) -> common.CheckResult:
23
+ logging.info(translator.translate("checking"))
24
+
25
+ ret = common.CheckResult()
26
+ ret.title = translator.translate("title")
27
+ ret.result_cols = ["Region", "GuardDuty Setting"]
28
+
29
+ ec2_client = session.client("ec2")
30
+
31
+ try:
32
+ regions = common.get_opted_in_regions(ec2_client)
33
+ except botocore.exceptions.ClientError as e:
34
+ logging.error(f"Error getting opted-in regions: {str(e)}", exc_info=True)
35
+ ret.level = level.error
36
+ ret.msg = translator.translate("unexpected_error")
37
+ ret.result_rows.append(["ERR", "ERR"])
38
+ ret.error_message = str(e)
39
+ return ret
40
+
41
+ with ThreadPoolExecutor() as executor:
42
+ futures = [
43
+ executor.submit(get_guard_duty_configuration, session.client("guardduty", region_name=region), region)
44
+ for region in regions
45
+ ]
46
+
47
+ nums_of_guardduty_enabled = 0
48
+ error_occurred = False
49
+
50
+ for future in as_completed(futures):
51
+ region, number_of_detectors = future.result()
52
+ if number_of_detectors == "ERR":
53
+ error_occurred = True
54
+ ret.result_rows.append([region, "ERR"])
55
+ elif number_of_detectors > 0:
56
+ nums_of_guardduty_enabled += 1
57
+ ret.result_rows.append([region, "Activated"])
58
+ else:
59
+ ret.result_rows.append([region, "Inactivated"])
60
+
61
+ if error_occurred:
62
+ ret.level = level.error
63
+ ret.msg = translator.translate("retrieval_error")
64
+ else:
65
+ ret.level = level.info
66
+ if nums_of_guardduty_enabled > 0:
67
+ ret.msg = translator.translate("is_activated")
68
+ else:
69
+ ret.msg = translator.translate("is_not_activated")
70
+
71
+ return ret
@@ -0,0 +1,43 @@
1
+ import logging
2
+
3
+ import botocore.exceptions
4
+ from utils import common
5
+ from utils import level_const as level
6
+
7
+
8
+ def check_iam_password_policy(session, translator) -> common.CheckResult:
9
+ logging.info(translator.translate("checking"))
10
+
11
+ client = session.client("iam")
12
+ ret = common.CheckResult()
13
+ ret.title = translator.translate("title")
14
+ ret.result_cols = ["Password Policy"]
15
+
16
+ try:
17
+ client.get_account_password_policy()
18
+ ret.level = level.success
19
+ ret.msg = translator.translate("success")
20
+ except client.exceptions.NoSuchEntityException:
21
+ ret.level = level.warning
22
+ ret.msg = translator.translate("warning")
23
+ ret.result_rows.append(["Not Set"])
24
+ except client.exceptions.ServiceFailureException as e:
25
+ logging.error(f"Service Failure: {str(e)}", exc_info=True)
26
+ ret.level = level.error
27
+ ret.msg = translator.translate("service_failure")
28
+ ret.result_rows.append(["ERR"])
29
+ ret.error_message = str(e)
30
+ except botocore.exceptions.ClientError as e:
31
+ logging.error(f"Unexpected ClientError: {str(e)}", exc_info=True)
32
+ ret.level = level.error
33
+ ret.msg = translator.translate("unexpected_error")
34
+ ret.result_rows.append(["ERR"])
35
+ ret.error_message = str(e)
36
+ except Exception as e:
37
+ logging.error(f"Unexpected error: {str(e)}", exc_info=True)
38
+ ret.level = level.error
39
+ ret.msg = translator.translate("unexpected_error")
40
+ ret.result_rows.append(["ERR"])
41
+ ret.error_message = str(e)
42
+
43
+ return ret
@@ -0,0 +1,39 @@
1
+ import logging
2
+
3
+ from utils import common
4
+ from utils import level_const as level
5
+
6
+
7
+ def check_iam_user_mfa_setting(session, translator, credential_report) -> common.CheckResult:
8
+ logging.info(translator.translate("checking"))
9
+
10
+ ret = common.CheckResult()
11
+ ret.title = translator.translate("title")
12
+ ret.result_cols = ["IAM User", "MFA Enabled"]
13
+
14
+ if not credential_report:
15
+ ret.level = level.error
16
+ ret.msg = translator.translate("credential_report_error")
17
+ ret.result_rows.append(["ERR", "ERR"])
18
+ return ret
19
+
20
+ user_info_list = common.get_iam_credential_report(credential_report)
21
+
22
+ if not user_info_list:
23
+ ret.level = level.warning
24
+ ret.msg = translator.translate("no_iam_user")
25
+ return ret
26
+
27
+ ret.level = level.success
28
+ ret.msg = translator.translate("success")
29
+
30
+ for user_info in user_info_list:
31
+ user_name = user_info[common.CREDENTIAL_REPORT_COLS.USER.value]
32
+ mfa_status = user_info[common.CREDENTIAL_REPORT_COLS.MFA_ACTIVE.value].upper()
33
+ ret.result_rows.append([user_name, mfa_status])
34
+
35
+ if mfa_status == "FALSE":
36
+ ret.level = level.danger
37
+ ret.msg = translator.translate("danger")
38
+
39
+ return ret
@@ -0,0 +1,55 @@
1
+ import logging
2
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
+
4
+ import botocore.exceptions
5
+ from utils import common
6
+ from utils import level_const as level
7
+
8
+
9
+ def get_instance_usage_by_region(client, region):
10
+ try:
11
+ number_of_instances = sum(
12
+ len(reservation["Instances"]) for reservation in client.describe_instances()["Reservations"]
13
+ )
14
+ return region, str(number_of_instances)
15
+ except botocore.exceptions.ClientError as e:
16
+ logging.error(f"Error getting instance usage for region {region}: {str(e)}", exc_info=True)
17
+ return region, "ERR"
18
+
19
+
20
+ def check_multiregion_instance_usage(session, translator) -> common.CheckResult:
21
+ logging.info(translator.translate("checking"))
22
+
23
+ ret = common.CheckResult()
24
+ ret.title = translator.translate("title")
25
+ ret.result_cols = ["Region", "Instance Usage"]
26
+
27
+ ec2_client = session.client("ec2")
28
+
29
+ try:
30
+ regions = common.get_opted_in_regions(ec2_client)
31
+ except botocore.exceptions.ClientError as e:
32
+ logging.error(f"Error getting opted-in regions: {str(e)}", exc_info=True)
33
+ ret.level = level.error
34
+ ret.msg = translator.translate("unexpected_error")
35
+ ret.result_rows.append(["ERR", "ERR"])
36
+ ret.error_message = str(e)
37
+ return ret
38
+
39
+ with ThreadPoolExecutor() as executor:
40
+ futures = [
41
+ executor.submit(get_instance_usage_by_region, session.client("ec2", region_name=region), region)
42
+ for region in regions
43
+ ]
44
+
45
+ ret.level = level.info
46
+ ret.msg = translator.translate("info_msg")
47
+
48
+ for future in as_completed(futures):
49
+ region, number_of_instances = future.result()
50
+ if number_of_instances == "ERR":
51
+ ret.level = level.error
52
+ ret.msg = translator.translate("retrieval_error")
53
+ ret.result_rows.append([region, number_of_instances])
54
+
55
+ return ret
@@ -0,0 +1,64 @@
1
+ import logging
2
+
3
+ import botocore.exceptions
4
+ from utils import common
5
+ from utils import level_const as level
6
+
7
+
8
+ def check_multi_region_trail_enabled(session, translator) -> common.CheckResult:
9
+ logging.info(translator.translate("checking"))
10
+
11
+ ret = common.CheckResult()
12
+ ret.title = translator.translate("title")
13
+ ret.result_cols = ["Trail", "Multi-Region Logging"]
14
+
15
+ client = session.client("cloudtrail")
16
+
17
+ try:
18
+ trails = client.describe_trails()["trailList"]
19
+ except (
20
+ client.exceptions.UnsupportedOperationException,
21
+ client.exceptions.OperationNotPermittedException,
22
+ client.exceptions.NoManagementAccountSLRExistsException,
23
+ botocore.exceptions.ClientError,
24
+ ) as e:
25
+ logging.error(f"Error describing trails: {str(e)}", exc_info=True)
26
+ ret.level = level.error
27
+ ret.msg = translator.translate("describe_trails_error")
28
+ ret.result_rows.append(["ERR", "ERR"])
29
+ ret.error_message = str(e)
30
+ return ret
31
+
32
+ if not trails:
33
+ ret.level = level.danger
34
+ ret.msg = translator.translate("no_trail")
35
+ ret.result_rows.append(["-", "-"])
36
+ return ret
37
+
38
+ logging_disabled_counter = 0
39
+ for trail in trails:
40
+ trail_arn = trail["TrailARN"]
41
+ try:
42
+ is_multi_region = client.get_trail(Name=trail_arn)["Trail"]["IsMultiRegionTrail"]
43
+ ret.result_rows.append([trail_arn, str(is_multi_region)])
44
+ if not is_multi_region:
45
+ logging_disabled_counter += 1
46
+ except botocore.exceptions.ClientError as e:
47
+ logging.error(f"Error getting trail {trail_arn}: {str(e)}", exc_info=True)
48
+ ret.result_rows.append([trail_arn, "ERR"])
49
+ ret.level = level.error
50
+ ret.msg = translator.translate("get_trail_error")
51
+ ret.error_message = str(e)
52
+
53
+ if ret.level != level.error:
54
+ if logging_disabled_counter == len(trails):
55
+ ret.level = level.danger
56
+ ret.msg = translator.translate("danger")
57
+ elif logging_disabled_counter > 0:
58
+ ret.level = level.warning
59
+ ret.msg = translator.translate("warning")
60
+ else:
61
+ ret.level = level.success
62
+ ret.msg = translator.translate("success")
63
+
64
+ return ret
@@ -0,0 +1,72 @@
1
+ import logging
2
+ from datetime import datetime, timedelta, timezone
3
+
4
+ from utils import common
5
+ from utils import level_const as level
6
+
7
+
8
+ def get_last_used_days(date):
9
+ if date in ("N/A", "no_information"):
10
+ return timedelta.max
11
+ return datetime.now(timezone.utc) - datetime.fromisoformat(date[:-6])
12
+
13
+
14
+ def format_last_used(last_used_days):
15
+ if last_used_days == timedelta.max:
16
+ return "N/A"
17
+ if last_used_days.days == 0:
18
+ return "Today"
19
+ return f"{last_used_days.days} days before"
20
+
21
+
22
+ def check_access_key(root_credential_report, key_number):
23
+ key_active = root_credential_report[
24
+ getattr(common.CREDENTIAL_REPORT_COLS, f"ACCESS_KEY_{key_number}_ACTIVE").value
25
+ ].upper()
26
+ key_last_used = root_credential_report[
27
+ getattr(common.CREDENTIAL_REPORT_COLS, f"ACCESS_KEY_{key_number}_LAST_USED_DATE").value
28
+ ]
29
+
30
+ if key_active == "TRUE":
31
+ last_used_days = get_last_used_days(key_last_used)
32
+ return f"AccessKey{key_number}", "In Use", format_last_used(last_used_days), True
33
+ return f"AccessKey{key_number}", "Not In Use", "N/A", False
34
+
35
+
36
+ def check_root_accesskey_usage(session, translator, credential_report) -> common.CheckResult:
37
+ logging.info(translator.translate("checking"))
38
+
39
+ ret = common.CheckResult()
40
+ ret.title = translator.translate("title")
41
+ ret.result_cols = ["Access Key", "Status", "Last Used Date"]
42
+
43
+ if not credential_report:
44
+ ret.level = level.error
45
+ ret.msg = translator.translate("credential_report_error")
46
+ ret.result_rows.append(["ERR", "ERR", "ERR"])
47
+ return ret
48
+
49
+ try:
50
+ root_credential_report = common.get_root_credential_report(credential_report)
51
+
52
+ key_in_use = False
53
+ for key_number in [1, 2]:
54
+ key, status, last_used, is_active = check_access_key(root_credential_report, key_number)
55
+ ret.result_rows.append([key, status, last_used])
56
+ key_in_use = key_in_use or is_active
57
+
58
+ if key_in_use:
59
+ ret.level = level.danger
60
+ ret.msg = translator.translate("danger")
61
+ else:
62
+ ret.level = level.success
63
+ ret.msg = translator.translate("success")
64
+
65
+ except Exception as e:
66
+ logging.error(f"Error processing root access key check: {str(e)}", exc_info=True)
67
+ ret.level = level.error
68
+ ret.msg = translator.translate("processing_error")
69
+ ret.result_rows.append(["ERR", "ERR", "ERR"])
70
+ ret.error_message = str(e)
71
+
72
+ return ret
@@ -0,0 +1,39 @@
1
+ import logging
2
+
3
+ from utils import common
4
+ from utils import level_const as level
5
+
6
+
7
+ def check_root_mfa_setting(session, translator, credential_report) -> common.CheckResult:
8
+ logging.info(translator.translate("checking"))
9
+
10
+ ret = common.CheckResult()
11
+ ret.title = translator.translate("title")
12
+ ret.result_cols = ["MFA Setting"]
13
+
14
+ if not credential_report:
15
+ ret.level = level.error
16
+ ret.msg = translator.translate("credential_report_error")
17
+ ret.result_rows.append(["ERR"])
18
+ return ret
19
+
20
+ try:
21
+ root_credential_report = common.get_root_credential_report(credential_report)
22
+ mfa_status = root_credential_report[common.CREDENTIAL_REPORT_COLS.MFA_ACTIVE.value].upper()
23
+ except Exception as e:
24
+ logging.error(f"Error processing root credential report: {str(e)}", exc_info=True)
25
+ ret.level = level.error
26
+ ret.msg = translator.translate("processing_error")
27
+ ret.result_rows.append(["ERR"])
28
+ ret.error_message = str(e)
29
+ return ret
30
+
31
+ if mfa_status == "TRUE":
32
+ ret.level = level.success
33
+ ret.msg = translator.translate("success")
34
+ else:
35
+ ret.level = level.danger
36
+ ret.msg = translator.translate("danger")
37
+
38
+ ret.result_rows.append([mfa_status])
39
+ return ret
@@ -0,0 +1,128 @@
1
+ from datetime import datetime, timedelta, timezone
2
+
3
+ from utils import common
4
+ from utils import level_const as level
5
+
6
+ from runbooks.utils.logger import configure_logger
7
+
8
+ logger = configure_logger(__name__) ## ✅ Configure Logger
9
+
10
+ ## Define the standard threshold for root account access
11
+ ROOT_ACCESS_DAYS_STANDARD = 7
12
+
13
+
14
+ ## @depreciated def get_root_access_days(date):
15
+ def get_root_access_days(date: str) -> timedelta:
16
+ """
17
+ Calculates the timedelta between a given ISO datetime string and now, with robust error handling.
18
+
19
+ Args:
20
+ date (str): The ISO format string to parse.
21
+
22
+ Returns:
23
+ timedelta: The time difference between the given date and now,
24
+ or timedelta.max for invalid or missing dates.
25
+ """
26
+ if not date or date in ("N/A", "no_information"):
27
+ return timedelta.max
28
+
29
+ try:
30
+ parsed_date = datetime.fromisoformat(date)
31
+ except ValueError:
32
+ try:
33
+ ## Strips the last 6 characters in the timezone offset (e.g., +00:00).
34
+ parsed_date = datetime.fromisoformat(date[:-6])
35
+ except ValueError:
36
+ logger.warning(f"Invalid date format encountered: {date}")
37
+ return timedelta.max
38
+
39
+ return datetime.now(timezone.utc) - parsed_date
40
+
41
+
42
+ def format_last_access_message(last_used_timedelta):
43
+ """
44
+ Formats a user-friendly message for last access time.
45
+
46
+ Args:
47
+ last_used_timedelta (timedelta): Time since last access.
48
+
49
+ Returns:
50
+ str: A descriptive message.
51
+ """
52
+ if last_used_timedelta == timedelta.max:
53
+ return "No history"
54
+ if last_used_timedelta.days == 0:
55
+ return "Today"
56
+ return f"{last_used_timedelta.days} days ago"
57
+
58
+
59
+ def check_root_usage(session, translator, credential_report) -> common.CheckResult:
60
+ """
61
+ Performs a security check on root account usage.
62
+
63
+ Args:
64
+ session: AWS session object (reserved for future extensions).
65
+ translator: Translator for multi-language support.
66
+ credential_report: Credential report from AWS.
67
+
68
+ Returns:
69
+ common.CheckResult: The result of the root usage security check.
70
+ """
71
+ logger.info(translator.translate("checking"))
72
+
73
+ result = common.CheckResult()
74
+ result.title = translator.translate("title")
75
+ result.result_cols = ["Credential Type", "Last Access Date"]
76
+
77
+ ## Handle missing credential report
78
+ if not credential_report:
79
+ result.level = level.error
80
+ result.msg = translator.translate("credential_report_error")
81
+ result.result_rows.append(["ERR", "No Credential Report Available"])
82
+ return result
83
+
84
+ try:
85
+ ## Retrieve root credential details
86
+ root_credential_report = common.get_root_credential_report(credential_report)
87
+
88
+ ## Calculate days since last usage for each credential type
89
+ password_last_used = get_root_access_days(
90
+ root_credential_report[common.CREDENTIAL_REPORT_COLS.PASSWORD_LAST_USED.value]
91
+ )
92
+ access_key1_last_used = get_root_access_days(
93
+ root_credential_report[common.CREDENTIAL_REPORT_COLS.ACCESS_KEY_1_LAST_USED_DATE.value]
94
+ )
95
+ access_key2_last_used = get_root_access_days(
96
+ root_credential_report[common.CREDENTIAL_REPORT_COLS.ACCESS_KEY_2_LAST_USED_DATE.value]
97
+ )
98
+
99
+ ## Determine the most recent access across all credentials
100
+ last_access_days = min(password_last_used, access_key1_last_used, access_key2_last_used)
101
+
102
+ if last_access_days > timedelta(ROOT_ACCESS_DAYS_STANDARD):
103
+ result.level = level.success
104
+ result.msg = translator.translate("success").format(ROOT_ACCESS_DAYS_STANDARD)
105
+ elif last_access_days.days == 0:
106
+ result.level = level.danger
107
+ result.msg = translator.translate("access_today")
108
+ else:
109
+ result.level = level.danger
110
+ result.msg = translator.translate("danger").format(last_access_days.days)
111
+
112
+ ## Add detailed result rows for each credential type
113
+ result.result_rows.extend(
114
+ [
115
+ ["PASSWORD", format_last_access_message(password_last_used)],
116
+ ["ACCESS KEY1", format_last_access_message(access_key1_last_used)],
117
+ ["ACCESS KEY2", format_last_access_message(access_key2_last_used)],
118
+ ]
119
+ )
120
+
121
+ except Exception as e:
122
+ logger.error(f"Error processing root usage check: {str(e)}", exc_info=True)
123
+ result.level = level.error
124
+ result.msg = translator.translate("processing_error")
125
+ result.result_rows.append(["ERR", "Processing Error"])
126
+ result.error_message = str(e)
127
+
128
+ return result