From 43cb4265384a2e0dded14aabdb4e7acbef80b303 Mon Sep 17 00:00:00 2001 From: a-shokri-ssc Date: Fri, 28 Feb 2025 09:58:56 -0500 Subject: [PATCH] Bug fixes25 02 15 (#121) * gr02-issues-#16 * GR06-GR12-bug-fixes-conformance-force-update * GR-03-06-07-12 * GR03 * 1.6 and 2 * reverting1.6-adding5.1 * cloudbrokerring role input update * reverting cb lookup lambda code * added empty file check * gr07 permission * updates * fixes * gr17 * 1.6-bugfixes and fine tuning * 4.6-lookup fix * 4.2-mngmt account fix * confromence-force-update * conformence update * revert * test for conformence update * conformence pack fix validation * indentation-client.py * indentation * indentation zip * cloudshell * cshell * conformence dummy resource * conformence pack deployment fix * gr12 and compile report fixes * zip shel cloud * gr1.6-create role delete comments * GR1.6 updates * gr1.6 * create role * 3.3 removal * removed cloudshell.zip --------- Co-authored-by: EC2 Default User --- .../customizations/GCGuardrailsRoles.yaml | 1 - .../AuditAccountPreRequisitesPart4.yaml | 18 - arch/templates/ConformancePack.yaml | 37 +- arch/templates/config-aggregator.yaml | 2 +- arch/templates/main.yaml | 46 +- doc/NOTES.md | 23 - .../audit_manager_custom_framework.py | 21 - src/lambda/aws_compile_audit_report/app.py | 644 +++++++++++++----- src/lambda/aws_create_role/app.py | 53 +- .../aws_lambda_permissions_setup/app.py | 1 - .../gc01_check_dedicated_admin_account/app.py | 581 +++++++++++----- .../app.py | 6 +- .../.gitignore | 244 ------- .../README.md | 47 -- .../__init__.py | 0 .../gc03_check_iam_cloudwatch_alarms/app.py | 214 ------ .../events/event.json | 62 -- .../requirements.txt | 0 .../template.yaml | 19 - .../app.py | 28 +- .../gc04_check_alerts_flag_misuse/app.py | 2 +- .../gc04_check_enterprise_monitoring/app.py | 108 +-- src/lambda/gc05_check_data_location/app.py | 54 +- .../app.py | 2 +- .../gc07_check_encryption_in_transit/app.py | 111 +++ .../gc12_check_private_marketplace/app.py | 153 ++++- .../site-packages/boto_util/client.py | 23 +- 27 files changed, 1347 insertions(+), 1153 deletions(-) delete mode 100644 src/lambda/gc03_check_iam_cloudwatch_alarms/.gitignore delete mode 100644 src/lambda/gc03_check_iam_cloudwatch_alarms/README.md delete mode 100644 src/lambda/gc03_check_iam_cloudwatch_alarms/__init__.py delete mode 100644 src/lambda/gc03_check_iam_cloudwatch_alarms/app.py delete mode 100644 src/lambda/gc03_check_iam_cloudwatch_alarms/events/event.json delete mode 100644 src/lambda/gc03_check_iam_cloudwatch_alarms/requirements.txt delete mode 100644 src/lambda/gc03_check_iam_cloudwatch_alarms/template.yaml diff --git a/arch/lza_extensions/customizations/GCGuardrailsRoles.yaml b/arch/lza_extensions/customizations/GCGuardrailsRoles.yaml index a067f342..07cefdfb 100644 --- a/arch/lza_extensions/customizations/GCGuardrailsRoles.yaml +++ b/arch/lza_extensions/customizations/GCGuardrailsRoles.yaml @@ -53,7 +53,6 @@ Resources: "arn:aws:sts::${AuditAccountID}:assumed-role/${RolePrefix}default_assessment_role/${OrganizationName}gc02_check_password_protection_mechanisms", "arn:aws:sts::${AuditAccountID}:assumed-role/${RolePrefix}default_assessment_role/${OrganizationName}gc02_check_privileged_roles_review", "arn:aws:sts::${AuditAccountID}:assumed-role/${RolePrefix}default_assessment_role/${OrganizationName}gc03_check_endpoint_access_config", - "arn:aws:sts::${AuditAccountID}:assumed-role/${RolePrefix}default_assessment_role/${OrganizationName}gc03_check_iam_cloudwatch_alarms", "arn:aws:sts::${AuditAccountID}:assumed-role/${RolePrefix}default_assessment_role/${OrganizationName}gc03_check_trusted_devices_admin_access", "arn:aws:sts::${AuditAccountID}:assumed-role/${RolePrefix}default_assessment_role/${OrganizationName}gc04_check_alerts_flag_misuse", "arn:aws:sts::${AuditAccountID}:assumed-role/${RolePrefix}default_assessment_role/${OrganizationName}gc04_check_enterprise_monitoring", diff --git a/arch/templates/AuditAccountPreRequisitesPart4.yaml b/arch/templates/AuditAccountPreRequisitesPart4.yaml index 85dcf16a..ba68dc21 100644 --- a/arch/templates/AuditAccountPreRequisitesPart4.yaml +++ b/arch/templates/AuditAccountPreRequisitesPart4.yaml @@ -269,24 +269,6 @@ Resources: Variables: DEFAULT_CLOUD_PROFILE: !Ref DefaultCloudProfile - GC03CheckIAMCloudWatchAlarmsLambda: - Condition: IsAuditAccount - Type: AWS::Lambda::Function - Properties: - FunctionName: !Sub "${OrganizationName}gc03_check_iam_cloudwatch_alarms" - Code: "../../src/lambda/gc03_check_iam_cloudwatch_alarms/build/GC03CheckIAMCloudWatchAlarmsLambda/" - Handler: app.lambda_handler - Role: !Sub "arn:aws:iam::${AuditAccountID}:role/${RolePrefix}default_assessment_role" - Runtime: !Ref PythonRuntime - Timeout: 180 - Layers: - - !Ref CloudGuardrailsCommonLayer - LoggingConfig: - LogGroup: !Sub "${OrganizationName}gc_guardrails" - LogFormat: "JSON" - Environment: - Variables: - DEFAULT_CLOUD_PROFILE: !Ref DefaultCloudProfile GC03CheckTrustedDevicesAdminAccessLambda: Condition: IsAuditAccount diff --git a/arch/templates/ConformancePack.yaml b/arch/templates/ConformancePack.yaml index 95585a83..c5b7c1e0 100644 --- a/arch/templates/ConformancePack.yaml +++ b/arch/templates/ConformancePack.yaml @@ -7,7 +7,7 @@ Parameters: # Common UpdateTriggerVersion: Type: String - Default: "v1" + Default: "v1.1" GCLambdaExecutionRoleName: Type: String GCLambdaExecutionRoleName2: @@ -111,13 +111,14 @@ Parameters: Type: String S3EmergencyAccountAlertsRuleNamesPath: Type: String + Resources: # GC01 GC01CheckAttestationLetterConfigRule: Type: "AWS::Config::ConfigRule" Properties: ConfigRuleName: gc01_check_attestation_letter - Description: Checks S3 bucket for the attestation letter + Description: Checks S3 bucket for the attestation letter. InputParameters: s3ObjectPath: Fn::If: @@ -690,38 +691,6 @@ Resources: SourceDetails: - EventSource: "aws.config" MessageType: "ScheduledNotification" - GC03CheckIAMCloudWatchAlarmsConfigRule: - Type: "AWS::Config::ConfigRule" - Properties: - ConfigRuleName: gc03_check_iam_cloudwatch_alarms - Description: Confirms if the ASEA CloudWatch Alarms for Unauthorized IPs and Sign-in without MFA are enabled - InputParameters: - ExecutionRoleName: - Fn::If: - - GCLambdaExecutionRoleName - - Ref: GCLambdaExecutionRoleName - - Ref: AWS::NoValue - AuditAccountID: - Fn::If: - - auditAccountID - - Ref: AuditAccountID - - Ref: AWS::NoValue - AlarmList: !Ref GC03AlarmList - Scope: - ComplianceResourceTypes: - - AWS::Account - MaximumExecutionFrequency: TwentyFour_Hours - Source: - Owner: CUSTOM_LAMBDA - SourceIdentifier: - Fn::Join: - - "" - - - "arn:aws:lambda:ca-central-1:" - - Ref: AuditAccountID - - !Sub ":function:${OrganizationName}gc03_check_iam_cloudwatch_alarms" - SourceDetails: - - EventSource: "aws.config" - MessageType: "ScheduledNotification" GC03CheckTrustedDevicesAdminAccessConfigRule: Type: "AWS::Config::ConfigRule" Properties: diff --git a/arch/templates/config-aggregator.yaml b/arch/templates/config-aggregator.yaml index edf423c9..106f0bdf 100644 --- a/arch/templates/config-aggregator.yaml +++ b/arch/templates/config-aggregator.yaml @@ -29,4 +29,4 @@ Resources: - 'sts:AssumeRole' Tags: - Key: "Source" - Value: "ProServe Delivery Kit" \ No newline at end of file + Value: "ProServe Delivery Kit" diff --git a/arch/templates/main.yaml b/arch/templates/main.yaml index 521d3c2c..809d662e 100644 --- a/arch/templates/main.yaml +++ b/arch/templates/main.yaml @@ -302,6 +302,8 @@ Resources: "organizations:ListTagsForResource", "organizations:Describe*", "organizations:List*", + "iam:Simulate*", + "iam:GetContextKeysForPrincipalPolicy", "qldb:DescribeLedger", "qldb:ListLedgers", "rds:Describe*", @@ -318,7 +320,20 @@ Resources: "sns:List*", "tag:GetResources", "timestream:DescribeEndpoints", - "timestream:List*" + "timestream:List*", + "iam:Simulate*", + "timestream:DescribeEndpoints", + "timestream:List*", + "iam:GetContextKeysForPrincipalPolicy", + "iam:SimulatePrincipalPolicy", + "aws-marketplace:Li*", + "aws-marketplace:D*", + "s3:GetBucket*", + "aws-marketplace:GetResourcePolicy", + "aws-marketplace:ListTagsForResource", + "identitystore:List*", + "es:Describe*", + "es:ListDomainNames" ], "Resource": [ "*" @@ -393,6 +408,7 @@ Resources: "Statement": [{ "Action": [ "acm:Describe*", + "iam:Simulate*", "acm:Get*", "acm:List*", "apigateway:GET", @@ -441,7 +457,16 @@ Resources: "sns:ListTopics", "tag:GetResources", "timestream:DescribeEndpoints", - "timestream:List*" + "timestream:List*", + "iam:GetContextKeysForPrincipalPolicy", + "iam:SimulatePrincipalPolicy", + "aws-marketplace:Li*", + "aws-marketplace:D*", + "s3:GetBucket*", + "aws-marketplace:GetResourcePolicy", + "aws-marketplace:ListTagsForResource", + "identitystore:List*" + ], "Resource": [ "*" @@ -481,6 +506,9 @@ Resources: - "logs:CreateLogGroup" - "logs:CreateLogStream" - "logs:PutLogEvents" + - "es:Describe*" + - "identitystore:List*" + Resource: - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${OrganizationName}gc*" Effect: Allow @@ -490,6 +518,8 @@ Resources: - "organizations:RegisterDelegatedAdministrator" - "organizations:Describe*" - "organizations:List*" + - "iam:Simulate*" + - "iam:GetContextKeysForPrincipalPolicy" Resource: "*" Effect: Allow PolicyName: gc_setup_config_lambda_execution_role_policy @@ -565,6 +595,8 @@ Resources: - "organizations:RegisterDelegatedAdministrator" - "organizations:Describe*" - "organizations:List*" + - "iam:Simulate*" + - "iam:GetContextKeysForPrincipalPolicy" Resource: "*" Effect: Allow PolicyName: !Sub "${OrganizationName}setup_auditmanager_lambda_execution_role_policy" @@ -1160,7 +1192,8 @@ Resources: - Sid: AllowES Action: - "es:ListDomainNames" - - "es:DescribeElasticsearchDomains" + - "es:DescribeDomain" + - "es:DescribeElasticsearchDomain" Resource: "*" Effect: Allow - Sid: AllowReadTags @@ -1346,6 +1379,7 @@ Resources: - "s3:ListAllMyBuckets" - "s3:ListBucket" - "s3:GetBucketPublicAccessBlock" + - "s3:GetBucketTagging" - "sso:ListInstances" - "identitystore:ListUsers" - "identitystore:ListGroups" @@ -1473,7 +1507,7 @@ Resources: - !Ref GCLambdaExecutionRole - !Ref GCLambdaExecutionRole2 - # ACM Access + # ACM Accessdescribe_domain GCLambdaExecutionRoleAcmPolicy: Condition: DeployRoles Type: AWS::IAM::Policy @@ -1513,7 +1547,7 @@ Resources: Properties: ConformancePackInputParameters: - ParameterName: UpdateTriggerVersion - ParameterValue: "v3" + ParameterValue: !Ref InvokeUpdate - ParameterName: GCLambdaExecutionRoleName ParameterValue: !Sub "${AccelRolePrefix}GCLambdaExecutionRole" - ParameterName: GCLambdaExecutionRoleName2 @@ -1898,4 +1932,4 @@ Outputs: GenerateEvidenceBucketName, !GetAtt GenerateEvidenceBucketName.EvidenceBucketName, !Ref EvidenceBucketName, - ] + ] \ No newline at end of file diff --git a/doc/NOTES.md b/doc/NOTES.md index 7c0eec69..e271d9a6 100644 --- a/doc/NOTES.md +++ b/doc/NOTES.md @@ -129,7 +129,6 @@ Note: since both buckets are generated by the same lambda function, ensure to pr f"{organization_name}gc02_check_account_mgmt_plan": ["GC02CheckAccountManagementPlanLambda"], f"{organization_name}gc02_check_iam_password_policy": ["GC02CheckIAMPasswordPolicyLambda"], f"{organization_name}gc03_check_endpoint_access_config": ["GC03CheckEndpointAccessConfigLambda"], - f"{organization_name}gc03_check_iam_cloudwatch_alarms": ["GC03CheckIAMCloudWatchAlarmsLambda"], f"{organization_name}gc03_check_trusted_devices_admin_access": ["GC03CheckTrustedDevicesAdminAccessLambda"], f"{organization_name}gc04_check_enterprise_monitoring": ["GC04CheckEnterpriseMonitoringLambda"], f"{organization_name}gc05_check_data_location": ["GC05CheckDataLocationLambda"], @@ -277,28 +276,6 @@ The following lambdas (starting with gc(n)\_ prefix) are used as part of AWS Con - Testing Status: SUCCESS ✅ - Note: evaluation not recorded in AWS Config when there are no IAM users -## gc03_check_iam_cloudwatch_alarms - -- Hardcoded values (defaults) 🔥 - - ```py - def check_cloudwatch_alarms( - alarm_names=[ - "ASEA-AWS-IAM-Authentication-From-Unapproved-IP", - "ASEA-AWS-SSO-Authentication-From-Unapproved-IP", - "ASEA-AWS-Console-SignIn-Without-MFA", - ] - ): - ``` - -- Linting info - - Score 9.15/10 💡 - - Line Length >100 (mainly loggers) - - Unused arguments - - Global variables (from main handler method) -- Check against the Management Account -- Testing Status: SUCCESS ✅ - ## gc04_check_enterprise_monitoring - No hardcoding ✅ diff --git a/src/lambda/aws_auditmanager_resources_config_setup/audit_manager_custom_framework.py b/src/lambda/aws_auditmanager_resources_config_setup/audit_manager_custom_framework.py index 7a006ff4..77690db7 100644 --- a/src/lambda/aws_auditmanager_resources_config_setup/audit_manager_custom_framework.py +++ b/src/lambda/aws_auditmanager_resources_config_setup/audit_manager_custom_framework.py @@ -337,27 +337,6 @@ ], "tags": {}, }, - { - "type": "Custom", - "name": "gc03_check_iam_cloudwatch_alarms", - "description": "Confirm ASEA CloudWatch Alarms are configured for access from Unauthorized IP addresses and sign-in without MFA..Source: https://github.com/canada-ca/cloud-guardrails/blob/master/EN/03_Secure-Endpoints.md", - "testingInformation": "", - "actionPlanTitle": "Review CloudWatch Alarms", - "actionPlanInstructions": "Go to AWS CloudWatch Alarms, and ensure alarms have been configured as required.", - "controlSources": "AWS Config", - "controlMappingSources": [ - { - "sourceName": "CW-check", - "sourceSetUpOption": "System_Controls_Mapping", - "sourceType": "AWS_Config", - "sourceKeyword": { - "keywordInputType": "SELECT_FROM_LIST", - "keywordValue": "Custom_gc03_check_iam_cloudwatch_alarms-conformance-pack", - }, - } - ], - "tags": {}, - }, { "type": "Custom", "name": "gc03_check_trusted_devices_admin_access", diff --git a/src/lambda/aws_compile_audit_report/app.py b/src/lambda/aws_compile_audit_report/app.py index 95413b73..2f30330d 100644 --- a/src/lambda/aws_compile_audit_report/app.py +++ b/src/lambda/aws_compile_audit_report/app.py @@ -6,204 +6,488 @@ import json import logging import os +import shutil +import time +import uuid +import concurrent.futures import boto3 +from botocore.exceptions import BotoCoreError, ClientError + from utils import get_cloud_profile_from_tags from boto_util.client import get_client from boto_util.organizations import get_account_tags -assessment_name = os.environ["ASSESSMENT_NAME"] -cac_version = os.environ["CAC_VERSION"] -org_id = os.environ["ORG_ID"] -org_name = os.environ["ORG_NAME"] -tenant_id = os.environ["TENANT_ID"] -auditManagerClient = boto3.client("auditmanager") -s3 = boto3.client("s3") +# CONFIGURATION +def get_required_env_var(name: str) -> str: + """Retrieve an environment variable or raise an error if missing.""" + value = os.environ.get(name) + if not value: + raise EnvironmentError(f"Required environment variable {name} is missing or empty.") + return value + +config = { + "ASSESSMENT_NAME": get_required_env_var("ASSESSMENT_NAME"), + "CAC_VERSION": get_required_env_var("CAC_VERSION"), + "ORG_ID": get_required_env_var("ORG_ID"), + "ORG_NAME": get_required_env_var("ORG_NAME"), + "TENANT_ID": get_required_env_var("TENANT_ID"), + "SOURCE_TARGET_BUCKET": get_required_env_var("source_target_bucket"), + "MAX_CONCURRENCY": int(os.environ.get("MAX_CONCURRENCY", "2")), + "TIME_LIMIT_BUFFER_SEC": 30, + "MAX_RETRIES": 3, + "CHUNK_FILE_PREFIX": "chunk_", + "STATE_FILE_NAME": "processing_state.json", + "DATE_FORMAT": "%Y-%m-%d", # For final S3 object naming +} + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +ACCOUNT_TAGS_CACHE = {} +def create_boto3_clients(): + return { + "auditmanager": boto3.client("auditmanager"), + "s3": boto3.client("s3"), + "lambda": boto3.client("lambda"), + "organizations": get_client("organizations", assume_role=False) + } + +# MAIN LAMBDA HANDLER def lambda_handler(event, context): - """This function is the main entry point for Lambda. - Keyword arguments: - event -- the event variable given in the lambda handler - context -- the context variable given in the lambda handler - """ - logger = logging.getLogger(__name__) - logger.setLevel(logging.INFO) - logger.info("start audit manager get assessments") - logger.info("Received Event: %s", json.dumps(event, indent=2)) - - header = [ - "accountId", - "accountCloudProfile", - "dataSource", - "guardrail", - "controlName", - "timestamp", - "resourceType", - "resourceArn", - "compliance", - "organizationId", - "organizationName", - "tenantId", - "cacVersion", - ] - csv_io = io.StringIO() - writer = csv.writer(csv_io) - writer.writerow(header) - - # get list of assessment and we will loop through each one - assessments = get_assessments(assessment_name) - if len(assessments) < 1: - return - count = 0 - for assessment in assessments: - assessment_id = assessment["id"] - evidence_folders = get_evidence_folders_by_assessment_id(assessment_id) - for folder in evidence_folders: - control_set_id = folder["controlSetId"] - folder_id = folder["id"] - control_id = folder["controlName"] - evidences = get_evidence_by_evidence_folders(assessment_id, control_set_id, folder_id) - for item in evidences: - aws_account_id = item["evidenceAwsAccountId"] - tags = get_account_tags(get_client("organizations", assume_role=False), aws_account_id) - cloud_profile = get_cloud_profile_from_tags(tags) - if item["time"] > (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(1)): - rows = [] - if len(item["resourcesIncluded"]) == 0: - rows.append( - [ - aws_account_id, - str(cloud_profile.value), - item["dataSource"], + logger.info("Lambda invocation started (structured).") + clients = create_boto3_clients() + + # Handle concurrency limit + current_concurrency = event.get("current_concurrency", 1) + if current_concurrency > config["MAX_CONCURRENCY"]: + logger.warning("Max concurrency reached (%s). Aborting this branch.", current_concurrency) + return {"status": "aborted_due_to_max_concurrency"} + + try: + result = process_assessments(event, context, current_concurrency, clients) + logger.info("Lambda finished with status: %s", result.get("status")) + return result + except Exception as e: + logger.error("Unexpected error in lambda_handler: %s", str(e), exc_info=True) + return {"status": "error", "message": str(e)} + +# CORE PROCESSING LOGIC +def process_assessments(event, context, current_concurrency, clients): + # Prepare a unique temp directory for this Lambda invocation + invocation_id = event.get("invocation_id") or str(uuid.uuid4()) + temp_dir = f"/tmp/{invocation_id}" + os.makedirs(temp_dir, exist_ok=True) + + # Load or initialize state + state_path = os.path.join(temp_dir, config["STATE_FILE_NAME"]) + state = load_state(state_path) or { + "assessment_name": config["ASSESSMENT_NAME"], + "assessment_index": 0, + "folder_index": 0, + "finished": False, + "chunks_written": [], + # Paginator positions + "assessments_done": False, + "folders_done": False, + "current_assessment_id": None, + } + + logger.info( + "Starting process with state => assessment_index: %d, folder_index: %d, finished: %s", + state["assessment_index"], + state["folder_index"], + state["finished"] + ) + + if state["finished"]: + finalize_and_cleanup_if_necessary(temp_dir, state, clients["s3"]) + return {"status": "already_finished"} + + assessment_list = list(get_all_assessments_paginated(clients["auditmanager"])) + if not assessment_list: + logger.info("No active assessments found.") + state["finished"] = True + save_state(state_path, state) + finalize_and_cleanup_if_necessary(temp_dir, state, clients["s3"]) + return {"status": "no_assessments"} + + if state["assessment_index"] >= len(assessment_list): + state["finished"] = True + save_state(state_path, state) + finalize_and_cleanup_if_necessary(temp_dir, state, clients["s3"]) + return {"status": "done"} + + # Current assessment + current_assessment = assessment_list[state["assessment_index"]] + assessment_id = current_assessment["id"] + state["current_assessment_id"] = assessment_id + save_state(state_path, state) + + # (2) Get all evidence folders for current assessment with a paginator. + folders = list(get_all_evidence_folders_paginated(clients["auditmanager"], assessment_id)) + if not folders: + state["assessment_index"] += 1 + state["folder_index"] = 0 + save_state(state_path, state) + if near_time_limit(context, config["TIME_LIMIT_BUFFER_SEC"]): + return self_invoke(clients["lambda"], event, invocation_id, current_concurrency, temp_dir, state_path) + return process_assessments(event, context, current_concurrency, clients) + + # (3) Process folders starting from the stored folder_index + while state["folder_index"] < len(folders): + folder = folders[state["folder_index"]] + control_set_id = folder["controlSetId"] + folder_id = folder["id"] + control_id = folder["controlName"] + + chunk_file = os.path.join(temp_dir, f"{config['CHUNK_FILE_PREFIX']}{uuid.uuid4()}.csv") + + try: + with open(chunk_file, "w", newline="") as csvfile: + writer = csv.writer(csvfile) + writer.writerow(OUTPUT_HEADER) + + all_evidence = get_all_evidence_paginated( + clients["auditmanager"], assessment_id, control_set_id, folder_id + ) + + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = [] + for evidence_items in all_evidence: + futures.append( + executor.submit( + process_evidence_items, + evidence_items, control_set_id, control_id, - item["time"].strftime("%m/%d/%Y %H:%M:%S %Z"), - "None", - "None", - "NOT_APPLICABLE", - org_id, - org_name, - tenant_id, - cac_version, - ] - ) - elif len(item["resourcesIncluded"]) > 1: - for sub_evidence in item["resourcesIncluded"]: - rows.append( - [ - aws_account_id, - str(cloud_profile.value), - item["dataSource"], - control_set_id, - control_id, - item["time"].strftime("%m/%d/%Y %H:%M:%S %Z"), - json.loads(sub_evidence["value"])["complianceResourceType"], - json.loads(sub_evidence["value"])["complianceResourceId"], - json.loads(sub_evidence["value"])["complianceType"], - org_id, - org_name, - tenant_id, - cac_version, - ] + clients["organizations"] ) - else: - if "value" not in item["resourcesIncluded"][0]: - rows.append( - [ - aws_account_id, - str(cloud_profile.value), - item["dataSource"], - control_set_id, - control_id, - item["time"].strftime("%m/%d/%Y %H:%M:%S %Z"), - "None", - "None", - "NOT_APPLICABLE", - org_id, - org_name, - tenant_id, - cac_version, - ] - ) - else: - rows.append( - [ - aws_account_id, - str(cloud_profile.value), - item["dataSource"], - control_set_id, - control_id, - item["time"].strftime("%m/%d/%Y %H:%M:%S %Z"), - json.loads(item["resourcesIncluded"][0]["value"])["complianceResourceType"], - json.loads(item["resourcesIncluded"][0]["value"])["complianceResourceId"], - json.loads(item["resourcesIncluded"][0]["value"])["complianceType"], - org_id, - org_name, - tenant_id, - cac_version, - ] - ) - for row in rows: - count += 1 - writer.writerow(row) - if count > 0: - s3.put_object( - Body=csv_io.getvalue(), - ContentType="text/csv", - Bucket=os.environ["source_target_bucket"], - Key=f'{datetime.datetime.today().strftime("%Y-%m-%d")}.csv', + ) + for f in concurrent.futures.as_completed(futures): + for row in f.result(): + writer.writerow(row) + + if os.path.getsize(chunk_file) > 0: + state["chunks_written"].append(chunk_file) + else: + os.remove(chunk_file) + + except Exception as e: + logger.error("Error writing partial CSV for folder %s: %s", folder_id, str(e)) + + state["folder_index"] += 1 + save_state(state_path, state) + + if near_time_limit(context, config["TIME_LIMIT_BUFFER_SEC"]): + return self_invoke(clients["lambda"], event, invocation_id, current_concurrency, temp_dir, state_path) + + state["assessment_index"] += 1 + state["folder_index"] = 0 + save_state(state_path, state) + + if state["assessment_index"] < len(assessment_list): + if near_time_limit(context, config["TIME_LIMIT_BUFFER_SEC"]): + return self_invoke(clients["lambda"], event, invocation_id, current_concurrency, temp_dir, state_path) + return process_assessments(event, context, current_concurrency, clients) + + state["finished"] = True + save_state(state_path, state) + finalize_and_cleanup_if_necessary(temp_dir, state, clients["s3"]) + return {"status": "success"} + +def safe_aws_call(fn, context_msg, *args, **kwargs): + """Wrapper to safely call AWS functions with retries & exponential backoff.""" + delay = 1 + for attempt in range(1, config["MAX_RETRIES"] + 1): + try: + return fn(*args, **kwargs) + except (BotoCoreError, ClientError) as e: + logger.warning( + "[Attempt %s/%s] AWS call failed (%s): %s", + attempt, + config["MAX_RETRIES"], + context_msg, + str(e) + ) + if attempt == config["MAX_RETRIES"]: + logger.error("Max retries reached for %s. Raising exception.", context_msg) + raise + time.sleep(delay) + delay *= 2 + +def get_all_assessments_paginated(auditmanager_client): + """ + Yield active assessments via manual pagination + """ + next_token = None + while True: + params = {"status": "ACTIVE"} + if next_token: + params["nextToken"] = next_token + + response = safe_aws_call( + auditmanager_client.list_assessments, + "list_assessments", + **params ) - csv_io.close() - return json.dumps("success", default=str) - else: - csv_io.close() - return json.dumps("Nothing to write", default=str) + for assessment in response.get("assessmentMetadata", []): + yield assessment + + next_token = response.get("nextToken") + if not next_token: + break +def get_all_evidence_folders_paginated(auditmanager_client, assessment_id): + next_token = None + while True: + params = {"assessmentId": assessment_id, "maxResults": 1000} + if next_token: + params["nextToken"] = next_token -def get_assessments(filter: str = None) -> list: - """Get list of all assessments if filter not provided. - If filter is provided return the single assessment. Filter - corresponds to assessment name. + response = safe_aws_call( + auditmanager_client.get_evidence_folders_by_assessment, + "get_evidence_folders_by_assessment", + **params + ) + for folder in response.get("evidenceFolders", []): + yield folder + + next_token = response.get("nextToken") + if not next_token: + break + +def get_all_evidence_paginated(auditmanager_client, assessment_id, control_set_id, folder_id): """ + Yields pages of evidence via manual pagination + """ + next_token = None + while True: + params = { + "assessmentId": assessment_id, + "controlSetId": control_set_id, + "evidenceFolderId": folder_id, + "maxResults": 500 + } + if next_token: + params["nextToken"] = next_token + + response = safe_aws_call( + auditmanager_client.get_evidence_by_evidence_folder, + "get_evidence_by_evidence_folder", + **params + ) + yield response.get("evidence", []) + + next_token = response.get("nextToken") + if not next_token: + break + +# EVIDENCE PROCESSING +OUTPUT_HEADER = [ + "accountId", + "accountCloudProfile", + "dataSource", + "guardrail", + "controlName", + "timestamp", + "resourceType", + "resourceArn", + "compliance", + "organizationId", + "organizationName", + "tenantId", + "cacVersion", +] + +def process_evidence_items(evidence_items, control_set_id, control_id, org_client): + """ + Convert AWS evidence items into CSV rows, filtered to last 24 hours. + """ + rows = [] + cutoff = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=1) + + for item in evidence_items: + evidence_time = item.get("time") + if evidence_time and evidence_time > cutoff: + aws_account_id = item.get("evidenceAwsAccountId", "UNKNOWN") + tags = get_account_tags_cached(org_client, aws_account_id) + cloud_profile = get_cloud_profile_from_tags(tags) + + data_source = item.get("dataSource", "UNKNOWN") + time_str = evidence_time.astimezone(datetime.timezone.utc).isoformat() + + resources = item.get("resourcesIncluded", []) + if not resources: + rows.append([ + aws_account_id, + str(cloud_profile.value), + data_source, + control_set_id, + control_id, + time_str, + "None", + "None", + "NOT_APPLICABLE", + config["ORG_ID"], + config["ORG_NAME"], + config["TENANT_ID"], + config["CAC_VERSION"], + ]) + else: + for r in resources: + val = r.get("value") + if not val: + rows.append([ + aws_account_id, + str(cloud_profile.value), + data_source, + control_set_id, + control_id, + time_str, + "None", + "None", + "NOT_APPLICABLE", + config["ORG_ID"], + config["ORG_NAME"], + config["TENANT_ID"], + config["CAC_VERSION"], + ]) + else: + try: + val_json = json.loads(val) + except json.JSONDecodeError: + val_json = {} + + rows.append([ + aws_account_id, + str(cloud_profile.value), + data_source, + control_set_id, + control_id, + time_str, + val_json.get("complianceResourceType", "None"), + val_json.get("complianceResourceId", "None"), + val_json.get("complianceType", "NOT_APPLICABLE"), + config["ORG_ID"], + config["ORG_NAME"], + config["TENANT_ID"], + config["CAC_VERSION"], + ]) + return rows + +def get_account_tags_cached(org_client, aws_account_id): + """Return cached tags for a given account id to reduce repeated lookups.""" + if aws_account_id in ACCOUNT_TAGS_CACHE: + return ACCOUNT_TAGS_CACHE[aws_account_id] try: - assessments = auditManagerClient.list_assessments(status="ACTIVE") - except (ValueError, TypeError) as err: - logging.error("Error at %s", "list_assessments", exc_info=err) - results = [] - else: - results = assessments["assessmentMetadata"] - print(results) - if filter is not None and len(results) != 0: - filtered_results = [] - for item in results: - if item["name"] == filter: - filtered_results.append(item) - return filtered_results - return results - - -def get_evidence_folders_by_assessment_id(id: str) -> list: - """Get list of all evidence folders for an assessment.""" - try: - response = auditManagerClient.get_evidence_folders_by_assessment(assessmentId=id, maxResults=1000) - except (ValueError, TypeError) as err: - logging.error("Error at %s", "get_evidence_folders_by_assessment", exc_info=err) - results = [] - else: - results = response["evidenceFolders"] - return results + tags = get_account_tags(org_client, aws_account_id) + ACCOUNT_TAGS_CACHE[aws_account_id] = tags + return tags + except Exception as e: + logger.error("Failed to get account tags for %s: %s", aws_account_id, e) + return {} -def get_evidence_by_evidence_folders(assessment_id: str, control_set_id: str, folder_id: str) -> list: - """Get list of all evidence for an evidence folder.""" +# STATE MANAGEMENT +def load_state(state_path): + """Load state JSON from local file if it exists.""" + if os.path.exists(state_path): + try: + with open(state_path, "r") as f: + return json.load(f) + except (IOError, json.JSONDecodeError) as e: + logger.error("Error loading state file %s: %s", state_path, str(e)) + return None + +def save_state(state_path, state): + """Save state as JSON to local file (atomic).""" + try: + with open(state_path, "w") as f: + json.dump(state, f) + except IOError as e: + logger.error("Failed to save state to %s: %s", state_path, str(e)) + +# SELF-INVOCATION +def self_invoke(lambda_client, event, invocation_id, current_concurrency, temp_dir, state_path): + """Re-invoke the same Lambda function with updated concurrency.""" try: - response_evidence_folder = auditManagerClient.get_evidence_by_evidence_folder( - assessmentId=assessment_id, controlSetId=control_set_id, evidenceFolderId=folder_id, maxResults=500 + new_event = { + **event, + "invocation_id": invocation_id, + "current_concurrency": current_concurrency + 1 + } + lambda_client.invoke( + FunctionName=os.environ["AWS_LAMBDA_FUNCTION_NAME"], + InvocationType="Event", + Payload=json.dumps(new_event), ) - except (ValueError, TypeError) as err: - logging.error("Error at %s", "get_evidence_by_evidence_folder", exc_info=err) - results = [] - else: - results = response_evidence_folder["evidence"] - return results + logger.info("Self-invoked with concurrency: %d -> %d", current_concurrency, current_concurrency + 1) + return {"status": "self_invoked", "concurrency": current_concurrency + 1} + except (BotoCoreError, ClientError) as e: + logger.error("Error self-invoking Lambda: %s", str(e), exc_info=True) + return {"status": "error_self_invoke", "message": str(e)} + +def near_time_limit(context, buffer_sec): + """Check if we're within `buffer_sec` seconds of Lambda's time limit.""" + return (context.get_remaining_time_in_millis() / 1000.0) < buffer_sec + +# CLEANUP +def finalize_and_cleanup_if_necessary(temp_dir, state, s3_client): + if state.get("finished"): + logger.info("State is finished. Merging partial CSV files.") + merged_csv = merge_chunk_files(state["chunks_written"], temp_dir) + if merged_csv: + final_key = f'{datetime.datetime.now(tz=datetime.timezone.utc).strftime(config["DATE_FORMAT"])}.csv' + try: + with open(merged_csv, "rb") as f: + safe_aws_call( + s3_client.put_object, + "upload_final_csv", + Body=f.read(), + ContentType="text/csv", + Bucket=config["SOURCE_TARGET_BUCKET"], + Key=final_key, + ) + logger.info(f"Merged CSV uploaded to S3: {final_key}") + except Exception as e: + logger.error("Error uploading merged CSV to S3: %s", str(e)) + cleanup_temp_directory(temp_dir) + +def merge_chunk_files(chunk_files, temp_dir): + """Stream-merge all partial CSV chunk files into a single CSV.""" + if not chunk_files: + logger.info("No chunk files to merge.") + return None + + merged_path = os.path.join(temp_dir, f"merged_{uuid.uuid4()}.csv") + header_written = False + + try: + with open(merged_path, "w", newline="") as outfile: + writer = None + for chunk_file in chunk_files: + if not os.path.exists(chunk_file): + continue + with open(chunk_file, "r") as infile: + reader = csv.reader(infile) + for i, row in enumerate(reader): + if i == 0: + if not header_written: + writer = csv.writer(outfile) + writer.writerow(row) + header_written = True + else: + writer.writerow(row) + except IOError as e: + logger.error("Error merging chunk files: %s", str(e)) + return None + + return merged_path + +def cleanup_temp_directory(temp_dir): + """Remove the entire temporary directory.""" + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir, ignore_errors=True) + logger.info("Cleaned up temp directory: %s", temp_dir) \ No newline at end of file diff --git a/src/lambda/aws_create_role/app.py b/src/lambda/aws_create_role/app.py index 4a29838a..efa3abe5 100644 --- a/src/lambda/aws_create_role/app.py +++ b/src/lambda/aws_create_role/app.py @@ -427,7 +427,58 @@ def lambda_handler(event, context): # If we get this far, cancel further reinvocations stop_reinvocation = True - + """ + # Handle Delete + elif request_type == 'Delete': + logger.info(f"CFN {request_type} request received") + for account in accounts: + # Skip the management account + if account_id in account['Id']: + continue + + try: + sts_session = assume_role(session=session, account_id=account['Id'], role_name=switch_role) + except Exception as err: + logger.error(f"Error assuming role: {err}") + response_data['Error'] = f"Error assuming role: {err}" + send(cfn_event, context, FAILED, response_data) + raise err + + # Detach all policies, delete the role + try: + detach_all_policies_from_role(session=sts_session, role_name=role_name) + delete_role(session=sts_session, role_name=role_name) + except Exception as err: + logger.info(f"Error deleting role {role_name}. Exception: {err}") + + # Delete each custom policy in policy_package["Docs"] + for policy_doc in policy_package["Docs"]: + try: + delete_iam_policy( + session=sts_session, + policy_arn=f"arn:aws:iam::{account['Id']}:policy/{policy_doc['Statement'][0]['Sid']}" + ) + except Exception as err: + logger.info( + f"Deleting Policy {policy_doc} in {account['Id']} failed. Exception: {err}" + ) + + rs = cfn_event['PhysicalResourceId'] + response_data['lower'] = rs.lower() if rs else '' + send(cfn_event, context, SUCCESS, response_data) + + # Cancel further reinvocations on success + stop_reinvocation = True + + else: + # Unknown RequestType + send(cfn_event, context, FAILED, response_data, response_data.get('lower', None)) + stop_reinvocation = True + except Exception as err: + logger.Error(f"Error occurred: {err}") + t.join() + raise err + """ except Exception as err: logger.Error(f"Error occurred: {err}") t.join() diff --git a/src/lambda/aws_lambda_permissions_setup/app.py b/src/lambda/aws_lambda_permissions_setup/app.py index eda6d858..1566d273 100644 --- a/src/lambda/aws_lambda_permissions_setup/app.py +++ b/src/lambda/aws_lambda_permissions_setup/app.py @@ -85,7 +85,6 @@ def apply_lambda_permissions(): f"{organization_name}gc02_check_password_protection_mechanisms": ["GC02CheckPasswordProtectionMechanismsLambda"], f"{organization_name}gc02_check_privileged_roles_review": ["GC02CheckPrivilegedRolesReviewLambda"], f"{organization_name}gc03_check_endpoint_access_config": ["GC03CheckEndpointAccessConfigLambda"], - f"{organization_name}gc03_check_iam_cloudwatch_alarms": ["GC03CheckIAMCloudWatchAlarmsLambda"], f"{organization_name}gc03_check_trusted_devices_admin_access": ["GC03CheckTrustedDevicesAdminAccessLambda"], f"{organization_name}gc04_check_alerts_flag_misuse": ["GC04CheckAlertsFlagMisuseLambda"], f"{organization_name}gc04_check_enterprise_monitoring": ["GC04CheckEnterpriseMonitoringLambda"], diff --git a/src/lambda/gc01_check_dedicated_admin_account/app.py b/src/lambda/gc01_check_dedicated_admin_account/app.py index 6c6e0d10..e9f919f4 100644 --- a/src/lambda/gc01_check_dedicated_admin_account/app.py +++ b/src/lambda/gc01_check_dedicated_admin_account/app.py @@ -28,25 +28,95 @@ def fetch_sso_users(sso_admin_client, identity_store_client): - sso_admin_instances = sso_admin_client.list_instances() + """ + Return two dictionaries: + 1) users_by_instance: { instance_arn: [ { "UserName", "UserId", ... }, ... ] } + 2) instance_id_by_arn: { instance_arn: identity_store_id } + """ + instances = list_all_sso_admin_instances(sso_admin_client) users_by_instance = {} + instance_id_by_arn = {} + + for i in instances: + if i.get("Status") != "ACTIVE": + continue + + instance_id = i.get("IdentityStoreId") + instance_arn = i.get("InstanceArn") + instance_id_by_arn[instance_arn] = instance_id + users_by_instance[instance_arn] = [] + next_token = None + + try: + while True: + response = ( + identity_store_client.list_users(IdentityStoreId=instance_id, NextToken=next_token) + if next_token + else identity_store_client.list_users(IdentityStoreId=instance_id) + ) + users_by_instance[instance_arn].extend(response.get("Users", [])) + next_token = response.get("NextToken") + if not next_token: + break + except botocore.exceptions.ClientError as ex: + ex.response["Error"]["Message"] = "InternalError" + ex.response["Error"]["Code"] = "InternalError" + raise ex - for i in sso_admin_instances.get("Instances", []): - if i.get("Status") == "ACTIVE": - instance_id = i.get("IdentityStoreId") - instance_arn = i.get("InstanceArn") + return users_by_instance, instance_id_by_arn - # Collect all the users for this instance - users_list = [] - response = identity_store_client.list_users(IdentityStoreId=instance_id) - users_list.extend(response.get("Users", [])) - users_by_instance[instance_arn] = {"instance_id": instance_id, "users": users_list} +def fetch_sso_group_ids_for_user(identity_store_client, identity_store_id, user_id): + """ + Return the list of group IDs for which the specified user is a member. + """ + group_ids = [] + # First, list all groups in the identity store. + groups = [] + next_token = None + while True: + response = ( + identity_store_client.list_groups(IdentityStoreId=identity_store_id, NextToken=next_token) + if next_token + else identity_store_client.list_groups(IdentityStoreId=identity_store_id) + ) + groups.extend(response.get("Groups", [])) + next_token = response.get("NextToken") + if not next_token: + break + + # Now, for each group, check if the user is a member. + for group in groups: + group_id = group.get("GroupId") + membership_next_token = None + while True: + response = ( + identity_store_client.list_group_memberships( + IdentityStoreId=identity_store_id, + GroupId=group_id, + NextToken=membership_next_token, + ) + if membership_next_token + else identity_store_client.list_group_memberships( + IdentityStoreId=identity_store_id, + GroupId=group_id, + ) + ) + for membership in response.get("GroupMemberships", []): + if membership.get("MemberId", {}).get("UserId") == user_id: + group_ids.append(group_id) + break # Found the user in this group; move on to the next group. + membership_next_token = response.get("NextToken") + if not membership_next_token: + break - return users_by_instance + return group_ids def policy_doc_gives_admin_access(policy_doc: str) -> bool: + """ + Check if the given JSON policy document has Effect=Allow, Action=*, Resource=* + """ document_dict = json.loads(policy_doc) statement = document_dict.get("Statement", []) for statement_component in statement: @@ -54,126 +124,193 @@ def policy_doc_gives_admin_access(policy_doc: str) -> bool: statement_component.get("Effect", "") == "Allow" and statement_component.get("Action", "") == "*" and statement_component.get("Resource", "") == "*" + and "Principal" in statement_component ): - return True + principal = statement_component["Principal"] + if isinstance(principal, dict) and "AWS" in principal: + aws_principal = principal["AWS"] + if isinstance(aws_principal, str) and aws_principal == f"arn:aws:iam::{mane_id}:root": + return True + elif isinstance(aws_principal, list) and f"arn:aws:iam::{mane_id}:root" in aws_principal: + return True return False def fetch_inline_user_policies(iam_client, user_name): - policies = [] + """ + Returns a list of inline policy documents (dicts with 'PolicyDocument') + for the specified user, including those inherited from any IAM groups. + """ + inline_policy_docs = [] marker = None + + # 1) Inline policies directly attached to the user + user_inline_policies = [] try: - # fetching policies directly attached to the user while True: response = ( iam_client.list_user_policies(UserName=user_name, Marker=marker) if marker else iam_client.list_user_policies(UserName=user_name) ) - policies = policies + response.get("PolicyNames", []) + user_inline_policies.extend(response.get("PolicyNames", [])) marker = response.get("Marker") if not marker: break - # fetching policies the user has access to through groups - groups = [] + for policy_name in user_inline_policies: + try: + pol_resp = iam_client.get_user_policy(UserName=user_name, PolicyName=policy_name) + inline_policy_docs.append({"PolicyDocument": pol_resp["PolicyDocument"]}) + except botocore.exceptions.ClientError as ex: + if "NoSuchEntity" in ex.response["Error"]["Code"]: + ex.response["Error"]["Message"] = ( + f"Unable to fetch inline policy '{policy_name}' for user '{user_name}'. No such entity found." + ) + elif "InvalidInput" in ex.response["Error"]["Code"]: + ex.response["Error"]["Message"] = ( + f"Invalid username '{user_name}' or policy '{policy_name}' input received." + ) + else: + ex.response["Error"]["Message"] = "InternalError" + ex.response["Error"]["Code"] = "InternalError" + raise ex + except botocore.exceptions.ClientError as ex: + if "NoSuchEntity" in ex.response["Error"]["Code"]: + ex.response["Error"]["Message"] = ( + f"Unable to fetch policies for user '{user_name}'. No such entity found." + ) + elif "InvalidInput" in ex.response["Error"]["Code"]: + ex.response["Error"]["Message"] = ( + f"Invalid username '{user_name}' or marker '{marker}' input received." + ) + else: + ex.response["Error"]["Message"] = "InternalError" + ex.response["Error"]["Code"] = "InternalError" + raise ex + + # 2) Inline policies from any IAM groups the user is in + groups = [] + marker = None + try: while True: response = ( iam_client.list_groups_for_user(UserName=user_name, Marker=marker) if marker else iam_client.list_groups_for_user(UserName=user_name) ) - groups = groups + response.get("Groups", []) - for g in groups: - inner_marker = None - while True: - response = ( - iam_client.list_group_policies(GroupName=g.get("GroupName"), Marker=inner_marker) - if inner_marker - else iam_client.list_group_policies(GroupName=g.get("GroupName")) - ) - policies = policies + response.get("PolicyNames", []) - inner_marker = response.get("Marker") - if not inner_marker: - break + groups.extend(response.get("Groups", [])) marker = response.get("Marker") - if not marker: break + for g in groups: + group_name = g["GroupName"] + group_marker = None + while True: + grp_resp = ( + iam_client.list_group_policies(GroupName=group_name, Marker=group_marker) + if group_marker + else iam_client.list_group_policies(GroupName=group_name) + ) + group_policy_names = grp_resp.get("PolicyNames", []) + group_marker = grp_resp.get("Marker") + + for gp_name in group_policy_names: + try: + gp_resp = iam_client.get_group_policy(GroupName=group_name, PolicyName=gp_name) + inline_policy_docs.append({"PolicyDocument": gp_resp["PolicyDocument"]}) + except botocore.exceptions.ClientError as ex: + if "NoSuchEntity" in ex.response["Error"]["Code"]: + ex.response["Error"]["Message"] = ( + f"Unable to fetch inline policy '{gp_name}' from group '{group_name}'." + ) + elif "InvalidInput" in ex.response["Error"]["Code"]: + ex.response["Error"]["Message"] = ( + f"Invalid group name '{group_name}' or policy '{gp_name}' input received." + ) + else: + ex.response["Error"]["Message"] = "InternalError" + ex.response["Error"]["Code"] = "InternalError" + raise ex + + if not group_marker: + break except botocore.exceptions.ClientError as ex: if "NoSuchEntity" in ex.response["Error"]["Code"]: - ex.response["Error"]["Message"] = f"Unable to fetch policies for user '{user_name}'. No such entity found." + ex.response["Error"]["Message"] = ( + f"Unable to fetch group policies for user '{user_name}'. No such entity found." + ) elif "InvalidInput" in ex.response["Error"]["Code"]: - ex.response["Error"]["Message"] = f"Invalid username '{user_name}' or marker '{marker}' input received." + ex.response["Error"]["Message"] = ( + f"Invalid user name '{user_name}' or marker '{marker}' input received." + ) else: ex.response["Error"]["Message"] = "InternalError" ex.response["Error"]["Code"] = "InternalError" raise ex - try: - for i in range(len(policies)): - policies[i] = iam_client.get_user_policy(user_name, policies[i]) - except botocore.exceptions.ClientError as ex: - if "NoSuchEntity" in ex.response["Error"]["Code"]: - ex.response["Error"]["Message"] = "Unable to fetch inline policy information. No such entity found." - elif "InvalidInput" in ex.response["Error"]["Code"]: - ex.response["Error"]["Message"] = "Failed to fetch inline policy information. Invalid input." - else: - ex.response["Error"]["Message"] = "InternalError" - ex.response["Error"]["Code"] = "InternalError" - - return policies + return inline_policy_docs def fetch_aws_managed_user_policies(iam_client, user_name): + """ + Returns a list of AWS-managed or customer-managed policies that are + attached directly to the user or inherited through IAM groups. + """ policies = [] marker = None + try: - # fetching policies directly attached to the user + # 1) Fetch policies attached directly to the user while True: response = ( iam_client.list_attached_user_policies(UserName=user_name, Marker=marker) if marker else iam_client.list_attached_user_policies(UserName=user_name) ) - policies = policies + response.get("AttachedPolicies", []) + policies += response.get("AttachedPolicies", []) marker = response.get("Marker") if not marker: break - # fetching policies the user has access to through groups + # 2) Fetch policies inherited from groups groups = [] + marker = None while True: response = ( iam_client.list_groups_for_user(UserName=user_name, Marker=marker) if marker else iam_client.list_groups_for_user(UserName=user_name) ) - groups = groups + response.get("Groups", []) - for g in groups: - inner_marker = None - while True: - response = ( - iam_client.list_attached_group_policies(GroupName=g.get("GroupName"), Marker=inner_marker) - if inner_marker - else iam_client.list_attached_group_policies(GroupName=g.get("GroupName")) - ) - policies = policies + response.get("AttachedPolicies", []) - inner_marker = response.get("Marker") - if not inner_marker: - break + groups += response.get("Groups", []) marker = response.get("Marker") - if not marker: break + for g in groups: + group_marker = None + while True: + grp_resp = ( + iam_client.list_attached_group_policies(GroupName=g.get("GroupName"), Marker=group_marker) + if group_marker + else iam_client.list_attached_group_policies(GroupName=g.get("GroupName")) + ) + policies += grp_resp.get("AttachedPolicies", []) + group_marker = grp_resp.get("Marker") + if not group_marker: + break + return policies except botocore.exceptions.ClientError as ex: if "NoSuchEntity" in ex.response["Error"]["Code"]: - ex.response["Error"]["Message"] = f"Unable to fetch policies for user '{user_name}'. No such entity found." + ex.response["Error"]["Message"] = ( + f"Unable to fetch policies for user '{user_name}'. No such entity found." + ) elif "InvalidInput" in ex.response["Error"]["Code"]: - ex.response["Error"]["Message"] = f"Invalid user name '{user_name}' or marker '{marker}' input received." + ex.response["Error"]["Message"] = ( + f"Invalid user name '{user_name}' or marker '{marker}' input received." + ) else: ex.response["Error"]["Message"] = "InternalError" ex.response["Error"]["Code"] = "InternalError" @@ -181,7 +318,12 @@ def fetch_aws_managed_user_policies(iam_client, user_name): def fetch_customer_managed_policy_documents_for_permission_set(iam_client, customer_managed_policies): + """ + Given a list of policy names, fetch the local (customer-managed) policy + documents from IAM for each. + """ policy_docs = [] + try: marker = None while True: @@ -190,13 +332,12 @@ def fetch_customer_managed_policy_documents_for_permission_set(iam_client, custo if marker else iam_client.list_policies(Scope="Local") ) - policies = response.get("Policies") - for p in policies: + for p in response.get("Policies", []): if p.get("PolicyName") in customer_managed_policies: p_doc_response = iam_client.get_policy_version( - p.get(PolicyArn="Arn"), VersionId=p.get("DefaultVersionId") + PolicyArn=p.get("Arn"), VersionId=p.get("DefaultVersionId") ) - policy_docs.append(p_doc_response.get("PolicyVersion").get("Document")) + policy_docs.append(p_doc_response.get("PolicyVersion", {}).get("Document")) marker = response.get("Marker") if not marker: break @@ -209,12 +350,16 @@ def fetch_customer_managed_policy_documents_for_permission_set(iam_client, custo def permission_set_has_admin_policies(iam_client, sso_admin_client, instance_arn, permission_set_arn): + """ + Determine if a given permission set includes Administrator privileges + (AWS-managed 'AdministratorAccess' or inline doc with * permissions). + """ managed_policies = [] inline_policies = [] - next_token = None + try: - # Fetch all AWS Managed policies for the permission set and add them to the list of managed policies + # 1) AWS-managed policies attached to this Permission Set while True: response = ( sso_admin_client.list_managed_policies_in_permission_set( @@ -225,18 +370,20 @@ def permission_set_has_admin_policies(iam_client, sso_admin_client, instance_arn InstanceArn=instance_arn, PermissionSetArn=permission_set_arn ) ) - managed_policies = managed_policies + response.get("AttachedManagedPolicies") + managed_policies += response.get("AttachedManagedPolicies", []) next_token = response.get("NextToken") if not next_token: break - # Fetch the inline document for the permission set if any exists. If none exists the response will still be valid, just an empty policy doc. + + # 2) Inline policies defined directly in the Permission Set response = sso_admin_client.get_inline_policy_for_permission_set( InstanceArn=instance_arn, PermissionSetArn=permission_set_arn ) - # If length is less than or equal to 1 then the policy doc is empty because there is no inline policy.The API specifies a min length of 1, but is a bit vague on what an empty policy doc would look like so we are covering the case of empty string - if len(response.get("InlinePolicy")) > 1: - inline_policies.append({"PolicyDocument": response.get("InlinePolicy")}) - # Fetch all customer managed policy references, convert the references into their policy document on the account, and add them to the list of inline policies. + if len(response.get("InlinePolicy", "")) > 1: + inline_policies.append({"PolicyDocument": response["InlinePolicy"]}) + + # 3) Customer-managed policies attached to the Permission Set + next_token = None while True: response = ( sso_admin_client.list_customer_managed_policy_references_in_permission_set( @@ -247,10 +394,10 @@ def permission_set_has_admin_policies(iam_client, sso_admin_client, instance_arn InstanceArn=instance_arn, PermissionSetArn=permission_set_arn ) ) - for policy_doc in fetch_customer_managed_policy_documents_for_permission_set( - iam_client, [p.get("Name") for p in response.get("CustomerManagedPolicyReferences")] - ): + customer_policy_names = [p.get("Name") for p in response.get("CustomerManagedPolicyReferences", [])] + for policy_doc in fetch_customer_managed_policy_documents_for_permission_set(iam_client, customer_policy_names): inline_policies.append({"PolicyDocument": policy_doc}) + next_token = response.get("NextToken") if not next_token: break @@ -263,13 +410,24 @@ def permission_set_has_admin_policies(iam_client, sso_admin_client, instance_arn def get_admin_permission_sets_for_instance_by_account(iam_client, sso_admin_client, instance_arn, organizations_client): + """ + For the given SSO instance, enumerate all accounts and figure out + which permission sets on each account have admin-level privileges. + """ permission_sets = {} - next_token = None try: + target_accounts = mane_id accounts = organizations_list_all_accounts(organizations_client) + if target_accounts: + accounts = [a for a in accounts if a.get("Id") in target_accounts] + + for a in accounts: account_id = a.get("Id") permission_sets[account_id] = [] + + + next_token = None while True: response = ( sso_admin_client.list_permission_sets_provisioned_to_account( @@ -280,7 +438,7 @@ def get_admin_permission_sets_for_instance_by_account(iam_client, sso_admin_clie AccountId=account_id, InstanceArn=instance_arn ) ) - for p_set in response.get("PermissionSets"): + for p_set in response.get("PermissionSets", []): if permission_set_has_admin_policies(iam_client, sso_admin_client, instance_arn, p_set): permission_sets[account_id].append(p_set) next_token = response.get("NextToken") @@ -294,7 +452,11 @@ def get_admin_permission_sets_for_instance_by_account(iam_client, sso_admin_clie return permission_sets -def user_assigned_to_permission_set(sso_admin_client, user_id, instance_arn, account_id, permission_set_arn): +def user_assigned_to_permission_set(sso_admin_client, user_id, user_group_ids, instance_arn, account_id, permission_set_arn): + """ + Check if the user is assigned to the given permission set either directly + (PrincipalType == 'USER') or via a group membership (PrincipalType == 'GROUP'). + """ try: next_token = None while True: @@ -307,12 +469,22 @@ def user_assigned_to_permission_set(sso_admin_client, user_id, instance_arn, acc ) if next_token else sso_admin_client.list_account_assignments( - AccountId=account_id, InstanceArn=instance_arn, PermissionSetArn=permission_set_arn + AccountId=account_id, + InstanceArn=instance_arn, + PermissionSetArn=permission_set_arn, ) ) - for assignment in response.get("AccountAssignments"): - if assignment.get("PrincipalId") == user_id: + for assignment in response.get("AccountAssignments", []): + principal_id = assignment.get("PrincipalId") + principal_type = assignment.get("PrincipalType") + + # Direct user assignment + if principal_type == "USER" and principal_id == user_id: + return True + # Group-based assignment + if principal_type == "GROUP" and principal_id in user_group_ids: return True + next_token = response.get("NextToken") if not next_token: break @@ -332,128 +504,140 @@ def check_users( non_privileged_user_list, event, aws_account_id, + sso_users_by_instance, + sso_instance_id_by_arn, ): + """ + 1) Check all IAM users (direct inline & attached policies, plus group memberships). + 2) Check all SSO users (direct assignment & group-based assignment to Permission Sets). + """ evaluations = [] + admin_users_detected = set() # CHANGED: track all users that have admin privileges at_least_one_privileged_user_has_admin_access = False non_privileged_user_with_admin_access = [] + # === IAM Checks === iam_users = list_all_iam_users(iam_client) - sso_users_by_instance = fetch_sso_users(sso_admin_client, identity_store_client) - - # IAM Check for u in iam_users: user_name = u.get("UserName") - logger.info(f"Checking iam users: {user_name}") - if user_name in privileged_user_list: - if at_least_one_privileged_user_has_admin_access == True: - continue - - managed_policies = fetch_aws_managed_user_policies(iam_client, user_name) - inline_policies = fetch_inline_user_policies(iam_client, user_name) - - at_least_one_privileged_user_has_admin_access = policies_grant_admin_access( - managed_policies, inline_policies - ) + logger.info(f"Checking IAM user: {user_name}") - elif user_name in non_privileged_user_list: - managed_policies = fetch_aws_managed_user_policies(iam_client, user_name) - inline_policies = fetch_inline_user_policies(iam_client, user_name) - if policies_grant_admin_access(managed_policies, inline_policies): - non_privileged_user_with_admin_access.append(user_name) + managed_policies = fetch_aws_managed_user_policies(iam_client, user_name) + inline_policies = fetch_inline_user_policies(iam_client, user_name) - # Identity Center Check - for instance_arn in sso_users_by_instance.keys(): - data = sso_users_by_instance[instance_arn] - instance_id = data["instance_id"] + # If this user has admin privileges, add them to admin_users_detected + if policies_grant_admin_access(managed_policies, inline_policies): + admin_users_detected.add(user_name) + # === SSO (Identity Center) Checks === + for instance_arn, sso_users in sso_users_by_instance.items(): admin_permission_sets_by_account = get_admin_permission_sets_for_instance_by_account( iam_client, sso_admin_client, instance_arn, organizations_client ) + instance_id = sso_instance_id_by_arn[instance_arn] - for user in data["users"]: - user_name = user.get("Username") - logger.info(f"checking sso instance user {user_name}") + for user in sso_users: + user_name = user.get("UserName") user_id = user.get("UserId") + # Get this user's SSO group memberships + user_group_ids = fetch_sso_group_ids_for_user(identity_store_client, instance_id, user_id) + logger.info(f"Checking SSO user: {user_name}") + if user_name in privileged_user_list: - if at_least_one_privileged_user_has_admin_access == True: + if at_least_one_privileged_user_has_admin_access: continue - at_least_one_privileged_user_has_admin_access = True - - elif user_name in non_privileged_user_list: - pass - - else: - for a_id in admin_permission_sets_by_account.keys(): - for p_arn in admin_permission_sets_by_account[a_id]: - if user_assigned_to_permission_set(sso_admin_client, user_id, instance_arn, a_id, p_arn): - at_least_one_privileged_user_has_admin_access = True - break - if at_least_one_privileged_user_has_admin_access: - break - - if not at_least_one_privileged_user_has_admin_access: - group_memberships_resp = identity_store_client.list_group_memberships( - IdentityStoreId=instance_id, - Filter={"MemberId": {"UserId": user_id}}, - ) - for membership in group_memberships_resp.get("GroupMemberships", []): - group_id = membership.get("GroupId") - for a_id in admin_permission_sets_by_account.keys(): - for p_arn in admin_permission_sets_by_account[a_id]: - if user_assigned_to_permission_set( - sso_admin_client, group_id, instance_arn, a_id, p_arn - ): - at_least_one_privileged_user_has_admin_access = True - break - if at_least_one_privileged_user_has_admin_access: - break - if at_least_one_privileged_user_has_admin_access: + # # Check if user or user's groups is assigned to an admin permission set + # for a_id, perm_sets in admin_permission_sets_by_account.items(): + # for p_arn in perm_sets: + # if user_assigned_to_permission_set(sso_admin_client, user_id, user_group_ids, instance_arn, a_id, p_arn): + # at_least_one_privileged_user_has_admin_access = True + # break + # if at_least_one_privileged_user_has_admin_access: + # break + # If user is assigned to any admin permission set, add to admin_users_detected + # for a_id, perm_sets in admin_permission_sets_by_account.items(): + # for p_arn in perm_sets: + # if user_assigned_to_permission_set(sso_admin_client, user_id, user_group_ids, instance_arn, a_id, p_arn): + # admin_users_detected.add(user_name) + # break + + # elif user_name in non_privileged_user_list: + # # Check if user or user's groups is assigned to an admin permission set + # for a_id, perm_sets in admin_permission_sets_by_account.items(): + # for p_arn in perm_sets: + # if user_assigned_to_permission_set(sso_admin_client, user_id, user_group_ids, instance_arn, a_id, p_arn): + # non_privileged_user_with_admin_access.append(user_name) + # break + # If user is assigned to any admin permission set, add to admin_users_detected + for a_id, perm_sets in admin_permission_sets_by_account.items(): + for p_arn in perm_sets: + if user_assigned_to_permission_set(sso_admin_client, user_id, user_group_ids, instance_arn, a_id, p_arn): + admin_users_detected.add(user_name) break - elif user_name in non_privileged_user_list: - for a_id in admin_permission_sets_by_account.keys(): - for p_arn in admin_permission_sets_by_account[a_id]: - if user_assigned_to_permission_set(sso_admin_client, user_id, instance_arn, a_id, p_arn): - non_privileged_user_with_admin_access.append(user_name) - - if at_least_one_privileged_user_has_admin_access and len(non_privileged_user_with_admin_access) == 0: + # === Final evaluations === + # if at_least_one_privileged_user_has_admin_access and len(non_privileged_user_with_admin_access) == 0: + # evaluations.append(build_evaluation(aws_account_id, "COMPLIANT", event)) + # else: + # failed_check_strings = [] + # if not at_least_one_privileged_user_has_admin_access: + # failed_check_strings.append("no privileged user with admin access was found") + # if len(non_privileged_user_with_admin_access) > 0: + # failed_check_strings.append( + # f"non_privileged users {non_privileged_user_with_admin_access} have admin access" + # ) + + # annotation = " and ".join([e for e in failed_check_strings if e]).capitalize() + # evaluations.append(build_evaluation(aws_account_id, "NON_COMPLIANT", event, annotation=annotation)) + + + # New exact-match logic + admin_users_detected = set([user.lower() for user in admin_users_detected]) + + priv_set = set(privileged_user_list) + nonpriv_set = set(non_privileged_user_list) + if admin_users_detected == priv_set and admin_users_detected.isdisjoint(nonpriv_set): evaluations.append(build_evaluation(aws_account_id, "COMPLIANT", event)) else: - failed_check_strings = [ - ( - "no privileged user with admin access was found" - if not at_least_one_privileged_user_has_admin_access - else None - ), - ( - f"non_privileged users {non_privileged_user_with_admin_access} have admin access" - if len(non_privileged_user_with_admin_access) > 0 - else None - ), - ] - annotation = " and ".join([e for e in failed_check_strings if e is not None]).capitalize() - evaluations.append(build_evaluation(aws_account_id, "NON_COMPLIANT", event, annotation=annotation)) + reasons = [] + if admin_users_detected > priv_set: + reasons.append( + # f"Admin users found {admin_users_detected} do not exactly match the provided privileged list {priv_set}" + f"Admin users {admin_users_detected - priv_set} do not exists in the provided privileged list" + ) + if admin_users_detected < priv_set: + reasons.append( + # f"Admin users found {admin_users_detected} do not exactly match the provided privileged list {priv_set}" + f"Users {priv_set - admin_users_detected} in the provided privileged list do not have admin access" + + ) + overlap = admin_users_detected.intersection(nonpriv_set) + if overlap: + reasons.append(f"Non-privileged users have admin access: {overlap}") + + annotation = " and ".join(reasons).capitalize() if reasons else "No match" + evaluations.append(build_evaluation(aws_account_id, "NON_COMPLIANT", event, annotation=annotation)) return evaluations def policies_grant_admin_access(managed_policies, inline_policies): - return next( - (True for p in managed_policies if p.get("PolicyName", p.get("Name", "")) == "AdministratorAccess"), False - ) or next((True for p in inline_policies if policy_doc_gives_admin_access(p.get("PolicyDocument", "{}"))), False) + """ + Return True if any of the given policies grants 'AdministratorAccess' + or if any inline document is effectively 'Action:*'/'Resource:*'. + """ + return any( + p.get("PolicyName", p.get("Name", "")) == "AdministratorAccess" for p in managed_policies + ) or any( + policy_doc_gives_admin_access(p.get("PolicyDocument", "{}")) for p in inline_policies + ) def lambda_handler(event, context): """ - This function is the main entry point for Lambda. - - Keyword arguments: - - event -- the event variable given in the lambda handler - - context -- the context variable given in the lambda handler + Main entry point for the AWS Lambda. """ logger.info("Received Event: %s", json.dumps(event, indent=2)) @@ -462,6 +646,7 @@ def lambda_handler(event, context): logger.error("Skipping assessments as this is not a scheduled invocation") return + # Load parameters from the Config rule rule_parameters = check_required_parameters( json.loads(event.get("ruleParameters", "{}")), ["ExecutionRoleName", "PrivilegedUsersFilePath", "NonPrivilegedUsersFilePath"], @@ -472,12 +657,16 @@ def lambda_handler(event, context): is_not_audit_account = aws_account_id != audit_account_id aws_organizations_client = get_client("organizations", aws_account_id, execution_role_name, is_not_audit_account) - + + + global mane_id + mane_id = get_organizations_mgmt_account_id(aws_organizations_client) + # Ensure we are in the Management Account if aws_account_id != get_organizations_mgmt_account_id(aws_organizations_client): - # We're not in the Management Account logger.info("Not checked in account %s as this is not the Management Account", aws_account_id) return + # Clients aws_config_client = get_client("config", aws_account_id, execution_role_name, is_not_audit_account) aws_iam_client = get_client("iam", aws_account_id, execution_role_name, is_not_audit_account) aws_sso_admin_client = get_client("sso-admin", aws_account_id, execution_role_name, is_not_audit_account) @@ -486,26 +675,39 @@ def lambda_handler(event, context): evaluations = [] - # Check cloud profile + # Guardrail checks tags = get_account_tags(get_client("organizations", assume_role=False), aws_account_id) cloud_profile = get_cloud_profile_from_tags(tags) gr_requirement_type = check_guardrail_requirement_by_cloud_usage_profile(GuardrailType.Guardrail1, cloud_profile) - # If the guardrail is recommended if gr_requirement_type == GuardrailRequirementType.Recommended: return submit_evaluations( aws_config_client, event, - [build_evaluation(aws_account_id, "COMPLIANT", event, gr_requirement_type=gr_requirement_type)], + [ + build_evaluation( + aws_account_id, + "COMPLIANT", + event, + gr_requirement_type=gr_requirement_type, + ) + ], ) - # If the guardrail is not required elif gr_requirement_type == GuardrailRequirementType.Not_Required: return submit_evaluations( aws_config_client, event, - [build_evaluation(aws_account_id, "NOT_APPLICABLE", event, gr_requirement_type=gr_requirement_type)], + [ + build_evaluation( + aws_account_id, + "NOT_APPLICABLE", + event, + gr_requirement_type=gr_requirement_type, + ) + ], ) + # S3 file checks privileged_users_file_path = rule_parameters.get("PrivilegedUsersFilePath", "") non_privileged_users_file_path = rule_parameters.get("NonPrivilegedUsersFilePath", "") @@ -516,10 +718,15 @@ def lambda_handler(event, context): annotation = f"No non_privileged user file input provided at {non_privileged_users_file_path}." evaluations.append(build_evaluation(aws_account_id, "NON_COMPLIANT", event, annotation=annotation)) else: - privileged_users_list = get_lines_from_s3_file(aws_s3_client, privileged_users_file_path) - non_privileged_users_list = get_lines_from_s3_file(aws_s3_client, non_privileged_users_file_path) + # Fetch user lists from S3 + privileged_users_list = [user.lower() for user in get_lines_from_s3_file(aws_s3_client, privileged_users_file_path)] + non_privileged_users_list = [user.lower() for user in get_lines_from_s3_file(aws_s3_client, non_privileged_users_file_path)] + + # Fetch SSO info + sso_users_by_instance, sso_instance_id_by_arn = fetch_sso_users(aws_sso_admin_client, aws_identity_store_client) - evaluations = evaluations + check_users( + # Main check + evaluations += check_users( aws_organizations_client, aws_sso_admin_client, aws_iam_client, @@ -528,7 +735,9 @@ def lambda_handler(event, context): non_privileged_users_list, event, aws_account_id, + sso_users_by_instance, + sso_instance_id_by_arn, ) logger.info("AWS Config updating evaluations: %s", evaluations) - submit_evaluations(aws_config_client, event, evaluations) + submit_evaluations(aws_config_client, event, evaluations) \ No newline at end of file diff --git a/src/lambda/gc02_check_group_access_configuration/app.py b/src/lambda/gc02_check_group_access_configuration/app.py index 3ae52d82..ead25515 100644 --- a/src/lambda/gc02_check_group_access_configuration/app.py +++ b/src/lambda/gc02_check_group_access_configuration/app.py @@ -414,6 +414,10 @@ def lambda_handler(event, context): annotation = "" management_account_id = get_organizations_mgmt_account_id(aws_organizations_client) admin_accounts = get_lines_from_s3_file(aws_s3_client, admin_accounts_s3_path) + if not admin_accounts: + evaluations.append(build_evaluation(aws_account_id,"NON_COMPLIANT",event,annotation="Admin accounts file is empty.",gr_requirement_type=gr_requirement_type)) + submit_evaluations(aws_config_client, event, evaluations) + return instances = list_all_sso_admin_instances(aws_sso_admin_client) identity_center_enabled = len([i for i in instances if i.get("Status", "") == "ACTIVE"]) > 0 @@ -450,7 +454,7 @@ def lambda_handler(event, context): else: has_non_admin_group = True - if has_non_admin_group: + if not has_non_admin_group: is_compliant = False annotation = ( "Account does not have an Identity Center group that does not provide administrator access." diff --git a/src/lambda/gc03_check_iam_cloudwatch_alarms/.gitignore b/src/lambda/gc03_check_iam_cloudwatch_alarms/.gitignore deleted file mode 100644 index 4808264d..00000000 --- a/src/lambda/gc03_check_iam_cloudwatch_alarms/.gitignore +++ /dev/null @@ -1,244 +0,0 @@ - -# Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode - -### Linux ### -*~ - -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - -# .nfs files are created when an open file is removed but is still being accessed -.nfs* - -### OSX ### -*.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### PyCharm ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff: -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/dictionaries - -# Sensitive or high-churn files: -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.xml -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml - -# Gradle: -.idea/**/gradle.xml -.idea/**/libraries - -# CMake -cmake-build-debug/ - -# Mongo Explorer plugin: -.idea/**/mongoSettings.xml - -## File-based project format: -*.iws - -## Plugin-specific files: - -# IntelliJ -/out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# Ruby plugin and RubyMine -/.rakeTasks - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -### PyCharm Patch ### -# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 - -# *.iml -# modules.xml -# .idea/misc.xml -# *.ipr - -# Sonarlint plugin -.idea/sonarlint - -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -.pytest_cache/ -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule.* - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ - -### VisualStudioCode ### -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -.history - -### Windows ### -# Windows thumbnail cache files -Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# Build folder - -*/build/* - -# End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode \ No newline at end of file diff --git a/src/lambda/gc03_check_iam_cloudwatch_alarms/README.md b/src/lambda/gc03_check_iam_cloudwatch_alarms/README.md deleted file mode 100644 index 3da06e05..00000000 --- a/src/lambda/gc03_check_iam_cloudwatch_alarms/README.md +++ /dev/null @@ -1,47 +0,0 @@ -*This readme file was created by AWS Bedrock: anthropic.claude-v2* - -# app.py - -## Overview - -This is a lambda function that checks for the existence of specific CloudWatch alarms related to IAM and console login events. It is intended to run in the AWS management account and submit evaluations to AWS Config. - -## Main Functions - -- `lambda_handler` - The main entry point for the lambda function. It checks if this is a scheduled invocation, and if so, calls the alarm checking function. - -- `check_cloudwatch_alarms` - Checks if the specified CloudWatch alarms exist. Returns a compliance status and annotation. - -- `build_evaluation` - Builds an evaluation object to submit to AWS Config. - -- `get_client` - Gets a boto3 client, using STS assume role if needed. - -- `get_organizations_mgmt_account_id` - Calls Organizations to get the management account ID. - -- `is_scheduled_notification` - Checks if the invocation is a scheduled notification. - -- `evaluate_parameters` - Evaluates rule parameters. - -## Input Events - -- Lambda is triggered by AWS Config on a scheduled basis. -- Event contains account ID, region, invoking event, rule parameters etc. - -## Output - -- Evaluations are submitted to AWS Config using the PutEvaluations API. - -## Permissions Required - -- `organizations:DescribeOrganization` - To determine management account ID -- `cloudwatch:DescribeAlarms` - To check for existence of alarms -- `config:PutEvaluations` - To submit evaluations to AWS Config -- `sts:AssumeRole` - If assuming roles is enabled - -## Logging - -- Uses Python logging to log to CloudWatch Logs. - -## Testing - -No automated testing is included. diff --git a/src/lambda/gc03_check_iam_cloudwatch_alarms/__init__.py b/src/lambda/gc03_check_iam_cloudwatch_alarms/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lambda/gc03_check_iam_cloudwatch_alarms/app.py b/src/lambda/gc03_check_iam_cloudwatch_alarms/app.py deleted file mode 100644 index 5b122a47..00000000 --- a/src/lambda/gc03_check_iam_cloudwatch_alarms/app.py +++ /dev/null @@ -1,214 +0,0 @@ -""" GC03 - Check IAM/ CloudWatch Alarms - https://canada-ca.github.io/cloud-guardrails/EN/03_Cloud-Console-Access.html -""" - -import json -import logging - -from utils import is_scheduled_notification, check_required_parameters, check_guardrail_requirement_by_cloud_usage_profile, get_cloud_profile_from_tags, GuardrailType, GuardrailRequirementType -from boto_util.organizations import get_account_tags, get_organizations_mgmt_account_id -from boto_util.client import get_client -from boto_util.config import build_evaluation, submit_evaluations - -import botocore.exceptions - -# Logging setup -logger = logging.getLogger() -logger.setLevel(logging.INFO) - - -def check_cloudwatch_alarms( - cloudwatch_client, - alarm_names=[ - "AWS-IAM-Authentication-From-Unapproved-IP", - "AWS-SSO-Authentication-From-Unapproved-IP", - "AWS-Console-SignIn-Without-MFA", - "AWSAccelerator-AWS-IAM-Authentication-From-Unapproved-IP", - "AWSAccelerator-AWS-SSO-Authentication-From-Unapproved-IP", - "AWSAccelerator-AWS-Console-SignIn-Without-MFA" - ], -): - """Check CloudWatch alarms for compliance. - Keyword arguments: - alarm_names -- the list of CloudWatch alarms to check - """ - result = {"status": "NON_COMPLIANT", "annotation": "No alarms found"} - if len(alarm_names) < 1: - # no alarms to check - result = { - "status": "COMPLIANT", - "annotation": "No alarms checked for compliance", - } - return result - # initialize our lists - alarms_not_found = alarm_names - alarms_found = [] - try: - # describe CloudWatch alarms - response = cloudwatch_client.describe_alarms( - AlarmNames=alarm_names, - AlarmTypes=["MetricAlarm"], - ) - # results may be paginated, and we may have to retry - b_more_data = True - i_retries = 0 - i_retry_limit = 10 - next_token = "" - while b_more_data and (i_retries < i_retry_limit): - # did we get a response? - if response: - # yes - alarms_found.extend(response.get("MetricAlarms")) - # results paginated? - next_token = response.get("NextToken") - if next_token: - # yes - response = cloudwatch_client.describe_alarms( - AlarmNames=alarm_names, - AlarmTypes=["MetricAlarm"], - NextToken=next_token, - ) - else: - # no more data - b_more_data = False - else: - logger.error("Empty response. Retry call.") - i_retries += 1 - if next_token: - response = cloudwatch_client.describe_alarms( - AlarmNames=alarm_names, - AlarmTypes=["MetricAlarm"], - NextToken=next_token, - ) - else: - response = cloudwatch_client.describe_alarms( - AlarmNames=alarm_names, - AlarmTypes=["MetricAlarm"], - ) - # did we time out trying? - if i_retries >= i_retry_limit: - # yes - result["annotation"] = "Empty response while trying describe_alarms in CloudWatch API." - return result - except botocore.exceptions.ClientError as error: - logger.error("Error while trying to describe_alarms - boto3 Client error - %s", error) - result["annotation"] = "Error while trying to describe_alarms." - return result - - # checking the alarms we found - alarms_not_found_set = set(alarms_not_found) - for alarm in alarms_found: - if not alarms_not_found_set: - # All alarms have been found, exit the loop - break - alarm_name = alarm.get("AlarmName") - if alarm_name: - for not_found_alarm in alarms_not_found_set: - if not_found_alarm in alarm_name: - logger.info("CloudWatch Alarm %s found.", alarm_name) - alarms_not_found_set.remove(not_found_alarm) - - # Stop the inner loop as we found a match - break - - # prepare the annotation (if needed) - if len(alarms_not_found_set) > 0: - annotation = "Alarms not found: " - for alarm in alarms_not_found_set: - annotation += f"{alarm}; " - result["annotation"] = annotation - else: - result = {"status": "COMPLIANT", "annotation": "All alarms found"} - - logger.info(result) - return result - - -def lambda_handler(event, context): - """ - This function is the main entry point for Lambda. - - Keyword arguments: - - event -- the event variable given in the lambda handler - - context -- the context variable given in the lambda handler - """ - logger.info("Received Event: %s", json.dumps(event, indent=2)) - - invoking_event = json.loads(event["invokingEvent"]) - if not is_scheduled_notification(invoking_event["messageType"]): - logger.error("Skipping assessments as this is not a scheduled invocation") - return - - rule_parameters = check_required_parameters( - json.loads(event.get("ruleParameters", "{}")), ["ExecutionRoleName", "AlarmList"] - ) - execution_role_name = rule_parameters.get("ExecutionRoleName") - audit_account_id = rule_parameters.get("AuditAccountID", "") - aws_account_id = event["accountId"] - is_not_audit_account = aws_account_id != audit_account_id - - evaluations = [] - - try: - client = get_client("organizations") - response = client.describe_account(AccountId=aws_account_id) - account_status = response["Account"]["Status"] - - logger.info(f"account_status is {account_status}") - - if account_status != "ACTIVE": - return - - aws_organizations_client = get_client("organizations", aws_account_id, execution_role_name) - - if aws_account_id != get_organizations_mgmt_account_id(aws_organizations_client): - logger.info( - "CloudWatch Alarms not checked in account %s as this is not the Management Account", - aws_account_id, - ) - return - - - aws_config_client = get_client("config", aws_account_id, execution_role_name) - aws_cloudwatch_client = get_client("cloudwatch", aws_account_id, execution_role_name) - - # Check cloud profile - tags = get_account_tags(get_client("organizations", assume_role=False), aws_account_id) - cloud_profile = get_cloud_profile_from_tags(tags) - gr_requirement_type = check_guardrail_requirement_by_cloud_usage_profile(GuardrailType.Guardrail3, cloud_profile) - - # If the guardrail is recommended - if gr_requirement_type == GuardrailRequirementType.Recommended: - return submit_evaluations(aws_config_client, event, [build_evaluation( - aws_account_id, - "COMPLIANT", - event, - gr_requirement_type=gr_requirement_type - )]) - # If the guardrail is not required - elif gr_requirement_type == GuardrailRequirementType.Not_Required: - return submit_evaluations(aws_config_client, event, [build_evaluation( - aws_account_id, - "NOT_APPLICABLE", - event, - gr_requirement_type=gr_requirement_type - )]) - - results = check_cloudwatch_alarms( - aws_cloudwatch_client, alarm_names=str(rule_parameters["AlarmList"]).split(",") - ) - if results: - compliance_type = results.get("status") - annotation = results.get("annotation") - else: - compliance_type = "NON_COMPLIANT" - annotation = "Unable to assess CloudWatch Alarms" - - logger.info(f"{compliance_type}: {annotation}") - evaluations.append(build_evaluation(aws_account_id, compliance_type, event, annotation=annotation)) - submit_evaluations(aws_config_client, event, evaluations) - - except: - logger.info("This account Id is not active. Compliance evaluation not available for suspended accounts") diff --git a/src/lambda/gc03_check_iam_cloudwatch_alarms/events/event.json b/src/lambda/gc03_check_iam_cloudwatch_alarms/events/event.json deleted file mode 100644 index a6197dea..00000000 --- a/src/lambda/gc03_check_iam_cloudwatch_alarms/events/event.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "body": "{\"message\": \"hello world\"}", - "resource": "/hello", - "path": "/hello", - "httpMethod": "GET", - "isBase64Encoded": false, - "queryStringParameters": { - "foo": "bar" - }, - "pathParameters": { - "proxy": "/path/to/resource" - }, - "stageVariables": { - "baz": "qux" - }, - "headers": { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, sdch", - "Accept-Language": "en-US,en;q=0.8", - "Cache-Control": "max-age=0", - "CloudFront-Forwarded-Proto": "https", - "CloudFront-Is-Desktop-Viewer": "true", - "CloudFront-Is-Mobile-Viewer": "false", - "CloudFront-Is-SmartTV-Viewer": "false", - "CloudFront-Is-Tablet-Viewer": "false", - "CloudFront-Viewer-Country": "US", - "Host": "1234567890.execute-api.us-east-1.amazonaws.com", - "Upgrade-Insecure-Requests": "1", - "User-Agent": "Custom User Agent String", - "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", - "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", - "X-Forwarded-For": "127.0.0.1, 127.0.0.2", - "X-Forwarded-Port": "443", - "X-Forwarded-Proto": "https" - }, - "requestContext": { - "accountId": "123456789012", - "resourceId": "123456", - "stage": "prod", - "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", - "requestTime": "09/Apr/2015:12:34:56 +0000", - "requestTimeEpoch": 1428582896000, - "identity": { - "cognitoIdentityPoolId": null, - "accountId": null, - "cognitoIdentityId": null, - "caller": null, - "accessKey": null, - "sourceIp": "127.0.0.1", - "cognitoAuthenticationType": null, - "cognitoAuthenticationProvider": null, - "userArn": null, - "userAgent": "Custom User Agent String", - "user": null - }, - "path": "/prod/hello", - "resourcePath": "/hello", - "httpMethod": "POST", - "apiId": "1234567890", - "protocol": "HTTP/1.1" - } -} diff --git a/src/lambda/gc03_check_iam_cloudwatch_alarms/requirements.txt b/src/lambda/gc03_check_iam_cloudwatch_alarms/requirements.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lambda/gc03_check_iam_cloudwatch_alarms/template.yaml b/src/lambda/gc03_check_iam_cloudwatch_alarms/template.yaml deleted file mode 100644 index 72ccd1fd..00000000 --- a/src/lambda/gc03_check_iam_cloudwatch_alarms/template.yaml +++ /dev/null @@ -1,19 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: > - gc03_check_iam_cloudwatch_alarms - -Globals: - Function: - Timeout: 180 - MemorySize: 128 - -Resources: - GC03CheckIAMCloudWatchAlarmsLambda: - Type: AWS::Serverless::Function - Properties: - CodeUri: . - Handler: app.lambda_handler - Runtime: python3.9 - Architectures: - - x86_64 diff --git a/src/lambda/gc03_check_trusted_devices_admin_access/app.py b/src/lambda/gc03_check_trusted_devices_admin_access/app.py index 500b3fb6..1e9401b4 100644 --- a/src/lambda/gc03_check_trusted_devices_admin_access/app.py +++ b/src/lambda/gc03_check_trusted_devices_admin_access/app.py @@ -5,6 +5,7 @@ import json import logging import ipaddress +import datetime from utils import is_scheduled_notification, check_required_parameters, check_guardrail_requirement_by_cloud_usage_profile, get_cloud_profile_from_tags, GuardrailType, GuardrailRequirementType from boto_util.organizations import get_account_tags, get_organizations_mgmt_account_id @@ -113,6 +114,7 @@ def lambda_handler(event, context): bg_account_names = [rule_parameters["BgUser1"], rule_parameters["BgUser2"]] lookup_attributes = [{"AttributeKey": "EventName", "AttributeValue": "ConsoleLogin"}] console_login_cloud_trail_events = lookup_cloud_trail_events(aws_cloudtrail_client, lookup_attributes) + print("INFO 1:", console_login_cloud_trail_events) cloud_trail_events = [e for e in console_login_cloud_trail_events if e.get("Username") not in bg_account_names] num_compliant_rules = 0 logger.info("Number of events found: %s", len(cloud_trail_events)) @@ -120,19 +122,33 @@ def lambda_handler(event, context): for lookup_event in cloud_trail_events: ct_event = json.loads(lookup_event.get("CloudTrailEvent", "{}")) ct_event_id = ct_event.get("eventID", "") - - if not ip_is_within_ranges(ct_event["sourceIPAddress"], vpn_ip_ranges): + ct_event_time = ct_event.get("eventTime", "") + + # get event time and convert to ISO format + ct_event_isotime = datetime.datetime.fromisoformat(ct_event_time.replace('Z', '+00:00')) + now = datetime.datetime.now(datetime.timezone.utc) + # find difference from the current/execution time + time_diff = now - ct_event_isotime + # this is one day in ISO format + one_day = datetime.timedelta(days=1) + + if time_diff > one_day: + # skip if event entry is older than 1 day + pass + elif not ip_is_within_ranges(ct_event["sourceIPAddress"], vpn_ip_ranges): compliance_type = "NON_COMPLIANT" - annotation = f"Cloud Trail Event '{ct_event_id}' has a source IP address OUTSIDE of the allowed ranges." + annotation = f"Cloud Trail Event '{ct_event_id}' has a source IP address OUTSIDE of the allowed ranges, with event time '{ct_event_time}'." + logger.info(f"{compliance_type}: {annotation}") + evaluations.append(build_evaluation(ct_event_id, compliance_type, event, "AWS::CloudTrail::Trail", annotation)) else: num_compliant_rules = num_compliant_rules + 1 compliance_type = "COMPLIANT" annotation = f"Cloud Trail Event '{ct_event_id}' has a source IP address inside of the allowed ranges." if account_has_federated_users(aws_iam_client): annotation = f"{annotation} Dependent on the compliance of the Federated IdP." - - logger.info(f"{compliance_type}: {annotation}") - evaluations.append(build_evaluation(ct_event_id, compliance_type, event, "AWS::CloudTrail::Trail", annotation)) + logger.info(f"{compliance_type}: {annotation}") + evaluations.append(build_evaluation(ct_event_id, compliance_type, event, "AWS::CloudTrail::Trail", annotation)) + if len(cloud_trail_events) == num_compliant_rules: compliance_type = "COMPLIANT" diff --git a/src/lambda/gc04_check_alerts_flag_misuse/app.py b/src/lambda/gc04_check_alerts_flag_misuse/app.py index f0937aa4..a2c71dda 100644 --- a/src/lambda/gc04_check_alerts_flag_misuse/app.py +++ b/src/lambda/gc04_check_alerts_flag_misuse/app.py @@ -278,4 +278,4 @@ def lambda_handler(event, context): ) ) - submit_evaluations(aws_config_client, event, evaluations) + submit_evaluations(aws_config_client, event, evaluations) \ No newline at end of file diff --git a/src/lambda/gc04_check_enterprise_monitoring/app.py b/src/lambda/gc04_check_enterprise_monitoring/app.py index cb78a541..76934d26 100644 --- a/src/lambda/gc04_check_enterprise_monitoring/app.py +++ b/src/lambda/gc04_check_enterprise_monitoring/app.py @@ -16,43 +16,69 @@ logger = logging.getLogger() logger.setLevel(logging.INFO) - -def check_enterprise_monitoring_accounts(aws_iam_client, trusted_principal, role_name): +def get_role_arn(iam_client, cb_role_pattern: str) -> str | None: """ - This function checks if the Enterprise Monitoring Account is configured + aws iam list-roles --query "Roles[?contains(RoleName, 'CloudBrokering')].[RoleName, Arn]" """ + try: + paginator = iam_client.get_paginator("list_roles") + matched_roles = [] + + for page in paginator.paginate(): + for role in page["Roles"]: + if cb_role_pattern in role["RoleName"]: + matched_roles.append(role) + + if not matched_roles: + return None + + # Return the ARN of the first matched role + return matched_roles[0]["Arn"] + except botocore.exceptions.ClientError as ex: + ex.response["Error"]["Message"] = "Error listing or matching roles." + ex.response["Error"]["Code"] = "InternalError" + raise ex + + + +def check_enterprise_monitoring_accounts(aws_iam_client, trusted_principal, role_pattern): b_role_found = False b_trust_policy_found = False try: - response = aws_iam_client.get_role(RoleName=role_name) - if response and response.get("Role", {}).get("RoleName") == role_name: - b_role_found = True - try: - policy_document = response.get("Role", {}).get("AssumeRolePolicyDocument") - except ValueError: - # invalid or empty policy - policy_document = {} - if policy_document: - for statement in policy_document.get("Statement"): - # check Principal - principal = statement.get("Principal", {}) - if principal: - aws = principal.get("AWS", "") - if ( - aws - and aws == trusted_principal - and (statement.get("Effect") == "Allow") - and (statement.get("Action") == "sts:AssumeRole") - ): - b_trust_policy_found = True - logger.info("Trust policy validated for role %s", role_name) - break - except botocore.exceptions.ClientError as err: - if "NoSuchEntity" in err.response["Error"]["Code"]: - b_role_found = False - else: - raise err - return {"RoleFound": b_role_found, "TrustPolicyFound": b_trust_policy_found} + matched_arn = get_role_arn(aws_iam_client, role_pattern) + if not matched_arn: + # No matching role found + return {"RoleFound": False, "TrustPolicyFound": False} + + # Extract the actual role name from the ARN + actual_role_name = matched_arn.split('/')[-1] + b_role_found = True + + # Retrieve the role and its trust policy + role_response = aws_iam_client.get_role(RoleName=actual_role_name) + policy_document = role_response["Role"].get("AssumeRolePolicyDocument", {}) + + if isinstance(policy_document, str): + policy_document = json.loads(policy_document) + + for statement in policy_document.get("Statement", []): + principal = statement.get("Principal", {}) + if principal: + aws = principal.get("AWS", "") + if ( + aws == trusted_principal + and statement.get("Effect") == "Allow" + and "sts:AssumeRole" in statement.get("Action", []) + ): + b_trust_policy_found = True + break + + except Exception as e: + logger.error("Error checking enterprise monitoring accounts: %s", e) + return {"RoleFound": False, "TrustPolicyFound": False} + + return {"RoleFound": b_role_found, "TrustPolicyFound": b_trust_policy_found} + def lambda_handler(event, context): @@ -73,22 +99,22 @@ def lambda_handler(event, context): return rule_parameters = check_required_parameters( - json.loads(event.get("ruleParameters", "{}")), ["ExecutionRoleName", "IAM_Role_Name", "IAM_Trusted_Principal"] + json.loads(event.get("ruleParameters", "{}")), + ["ExecutionRoleName", "IAM_Role_Name", "IAM_Trusted_Principal"] ) execution_role_name = rule_parameters.get("ExecutionRoleName") - audit_account_id = rule_parameters.get("AuditAccountID", "") aws_account_id = event["accountId"] - is_not_audit_account = aws_account_id != audit_account_id - evaluations = [] - aws_organizations_client = get_client("organizations", aws_account_id, execution_role_name) - - if aws_account_id != get_organizations_mgmt_account_id(aws_organizations_client): + mgmt_account_id = get_organizations_mgmt_account_id(aws_organizations_client) + # lets skip if not mngt acc + if aws_account_id != mgmt_account_id: logger.info( - "Enterprise Monitoring Accounts not checked in account %s as this is not the Management Account", - aws_account_id, + "Account %s is not the management account (%s). skipping checks.", + aws_account_id, mgmt_account_id ) + return + aws_config_client = get_client("config", aws_account_id, execution_role_name) aws_iam_client = get_client("iam", aws_account_id, execution_role_name) diff --git a/src/lambda/gc05_check_data_location/app.py b/src/lambda/gc05_check_data_location/app.py index 82be5b5a..5c4eefe3 100644 --- a/src/lambda/gc05_check_data_location/app.py +++ b/src/lambda/gc05_check_data_location/app.py @@ -135,6 +135,30 @@ def get_qldb_resources(aws_account_id, execution_role_name, RegionName=None, eve return results +def s3_has_approved_tags(bucket_tags): + """Checks if s3 bucket has approved tags attached for location exemption + Args: + output from an s3 client get_bucket_tagging(Bucket='bucket_name') + + Returns: + True if bucket is tagged with approved tag(s) + False otherwise + """ + # Tag keys + TAG_KEY_DATA_CLASS = "Data classification" + # Allowed tag values for each key + ALLOWED_DATA_CLASS_VALUES = ["Protected A", "Unclassified"] + + if bucket_tags == None: + return False + elif 'TagSet' in bucket_tags: + for tag in bucket_tags['TagSet']: + if tag['Key'] == TAG_KEY_DATA_CLASS and tag['Value'] in ALLOWED_DATA_CLASS_VALUES: + return True + else: + pass + return False + def get_s3_resources(aws_s3_client, UnauthorizedRegionsList=[]): """ Finds Amazon S3 resources in the specified region @@ -147,25 +171,39 @@ def get_s3_resources(aws_s3_client, UnauthorizedRegionsList=[]): for bucket in response.get("Buckets"): bucket_name = bucket.get("Name") bucket_arn = "arn:aws:s3:::{}".format(bucket_name) - response2 = aws_s3_client.get_bucket_location(Bucket=bucket_name) - if response2: - LocationConstraint = response2.get("LocationConstraint") + bucket_location = aws_s3_client.get_bucket_location(Bucket=bucket_name) + + # bucket may not have tags + try: + bucket_tags = aws_s3_client.get_bucket_tagging(Bucket=bucket_name) + except Exception as e: + if "NoSuchTagSet" in e.response["Error"]["Code"]: + bucket_tags = None + + if bucket_location: + LocationConstraint = bucket_location.get("LocationConstraint") if LocationConstraint: if LocationConstraint in UnauthorizedRegionsList: - if results.get(LocationConstraint): + # if bucket does not have the proper tags for exemption, + # add bucket info to results[LocationConstraint] dict + if not s3_has_approved_tags(bucket_tags) and results.get(LocationConstraint): + # results[LocationConstraint] exists, then just append results[LocationConstraint].append( {"Arn": bucket_arn, "Id": bucket.get("Name"), "ResourceType": ResourceType} ) - else: + elif not s3_has_approved_tags(bucket_tags) and not results.get(LocationConstraint): + # results[LocationConstraint] doesn't exist, so start one results[LocationConstraint] = [ {"Arn": bucket_arn, "Id": bucket.get("Name"), "ResourceType": ResourceType} ] else: - if results.get("global"): + if not s3_has_approved_tags(bucket_tags) and results.get("global"): + # results["global"] exists, then just append results["global"].append( {"Arn": bucket_arn, "Id": bucket.get("Name"), "ResourceType": ResourceType} ) - else: + elif not s3_has_approved_tags(bucket_tags) and not results.get("global"): + # results["global"] doesn't exist, so start one results["global"] = [ {"Arn": bucket_arn, "Id": bucket.get("Name"), "ResourceType": ResourceType} ] @@ -518,4 +556,4 @@ def lambda_handler(event, context): evaluations.append(build_evaluation(aws_account_id, complianceStatus, event, annotation=annotation)) logger.info(f"{complianceStatus}: {annotation}") - submit_evaluations(aws_config_client, event, evaluations) + submit_evaluations(aws_config_client, event, evaluations) \ No newline at end of file diff --git a/src/lambda/gc06_check_encryption_at_rest_part2/app.py b/src/lambda/gc06_check_encryption_at_rest_part2/app.py index 9d8dabb1..a2314de7 100644 --- a/src/lambda/gc06_check_encryption_at_rest_part2/app.py +++ b/src/lambda/gc06_check_encryption_at_rest_part2/app.py @@ -75,7 +75,7 @@ def assess_open_search_encryption_at_rest(open_search_client, event): # - 'AWS::Elasticsearch::Domain' for ES domains # - 'AWS::OpenSearchService::Domain' for OpenSearch domains if engine_type == "OpenSearch": - resource_type = "AWS::OpenSearchService::Domain" + resource_type = "AWS::Elasticsearch::Domain" else: resource_type = "AWS::Elasticsearch::Domain" diff --git a/src/lambda/gc07_check_encryption_in_transit/app.py b/src/lambda/gc07_check_encryption_in_transit/app.py index 6b23be61..19d93c13 100644 --- a/src/lambda/gc07_check_encryption_in_transit/app.py +++ b/src/lambda/gc07_check_encryption_in_transit/app.py @@ -320,6 +320,114 @@ def assess_elb_v2_ssl_enforcement(elb_v2_client, event: dict): # logger.info("ELBv2 - reporting %s evaluations.", len(local_evaluations)) return local_evaluations +def assess_elb_v1_ssl_enforcement(elb_client, event: dict): + """ + Evaluate whether SSL is enforced on Classic Load Balancers (ELB v1). + """ + local_evaluations = [] + load_balancers = [] + + try: + response = elb_client.describe_load_balancers() + b_more_data = True + i_retries = 0 + while b_more_data and i_retries < MAXIMUM_API_RETRIES: + if response: + next_marker = response.get("NextMarker", "") + for load_balancer in response.get("LoadBalancerDescriptions", []): + load_balancers.append( + { + "LoadBalancerName": load_balancer.get("LoadBalancerName"), + "DNSName": load_balancer.get("DNSName"), + "ListenerDescriptions": load_balancer.get("ListenerDescriptions", []), + } + ) + if next_marker: + time.sleep(INTERVAL_BETWEEN_API_CALLS) + try: + response = elb_client.describe_load_balancers(Marker=next_marker) + except botocore.exceptions.ClientError as ex: + i_retries += 1 + if is_throttling_exception(ex): + time.sleep(THROTTLE_BACKOFF) + else: + raise ex + else: + b_more_data = False + else: + logger.error("ELBv1 - Empty response while trying to describe_load_balancers") + b_more_data = False + except botocore.exceptions.ClientError as ex: + raise ex + + #logger.info(f"Found {len(load_balancers)} Classic Load Balancers: {[lb['LoadBalancerName'] for lb in load_balancers]}") + + for load_balancer in load_balancers: + try: + for listener in load_balancer.get("ListenerDescriptions", []): + listener_compliance = "" + listener_annotation = "" + listener_port = listener.get("Listener", {}).get("LoadBalancerPort", "") + listener_protocol = listener.get("Listener", {}).get("Protocol", "") + + if listener_protocol.lower() not in ["https", "ssl"]: + listener_compliance = "NON_COMPLIANT" + listener_annotation = f"Port {listener_port} uses non TLS 1.2 compliant listener protocol {listener_protocol}" + else: + policy_names = listener.get("PolicyNames", []) + + if not policy_names: # No policy found for the listener + listener_compliance = "NON_COMPLIANT" + listener_annotation = f"Port {listener_port} has no TLS 1.2 compliant policy attached for {listener_protocol}" + else: + + for policy_name in policy_names: + + try: + policy_response = elb_client.describe_load_balancer_policies(LoadBalancerName=load_balancer.get('LoadBalancerName'), PolicyNames=[policy_name]) + listener_security_policy = next( + (attr["AttributeValue"] for attr in policy_response.get("PolicyDescriptions", [{}])[0].get("PolicyAttributeDescriptions", []) + if attr.get("AttributeName") == "Reference-Security-Policy"), + None + ) + + if listener_security_policy: # Only log if a value is found + if listener_security_policy == "ELBSecurityPolicy-TLS-1-2-2017-01": + listener_compliance = "COMPLIANT" + listener_annotation = ( + f"Port {listener_port} uses TLS 1.2 compliant {listener_protocol} security policy {listener_security_policy}" + ) + else: + listener_compliance = "NON_COMPLIANT" + listener_annotation = ( + f"Port {listener_port} uses non-TLS 1.2 compliant {listener_protocol} security policy {listener_security_policy}" + ) + else: + # If Reference-Security-Policy is not found, mark as NON_COMPLIANT + listener_compliance = "NON_COMPLIANT" + listener_annotation = ( + f"Port {listener_port} is missing the Reference-Security-Policy: ELBSecurityPolicy-TLS-1-2-2017-01" + ) + except botocore.exceptions.ClientError as ex: + logger.error(f"Error retrieving policy details for {policy_name}: {str(ex)}") + + local_evaluations.append( + build_evaluation( + load_balancer.get("DNSName", ""), + listener_compliance, + event, + "AWS::ElasticLoadBalancing::LoadBalancer", + annotation=listener_annotation, + ) + ) + except botocore.exceptions.ClientError as ex: + i_retries += 1 + if is_throttling_exception(ex): + time.sleep(THROTTLE_BACKOFF) + else: + raise ex + + return local_evaluations def assess_rest_api_stages_ssl_enforcement(api_gw_client, event: dict): """ @@ -624,6 +732,7 @@ def lambda_handler(event, context): execution_role_name = rule_parameters.get("ExecutionRoleName") audit_account_id = rule_parameters.get("AuditAccountID", "") aws_account_id = event["accountId"] + logger.info(f"AWS Account ID: {aws_account_id}, Audit Account ID: {audit_account_id}") is_not_audit_account = aws_account_id != audit_account_id evaluations = [] @@ -641,6 +750,7 @@ def lambda_handler(event, context): aws_config_client = get_client("config", aws_account_id, execution_role_name, is_not_audit_account) aws_s3_client = get_client("s3", aws_account_id, execution_role_name, is_not_audit_account) aws_redshift_client = get_client("redshift", aws_account_id, execution_role_name, is_not_audit_account) + aws_elb_v1_client = get_client("elb", aws_account_id, execution_role_name, is_not_audit_account) aws_elb_v2_client = get_client("elbv2", aws_account_id, execution_role_name, is_not_audit_account) aws_api_gw_client = get_client("apigateway", aws_account_id, execution_role_name, is_not_audit_account) aws_open_search_client = get_client("opensearch", aws_account_id, execution_role_name, is_not_audit_account) @@ -678,6 +788,7 @@ def lambda_handler(event, context): evaluations.extend(assess_s3_buckets_ssl_enforcement(aws_s3_client, event)) evaluations.extend(assess_redshift_clusters_ssl_enforcement(aws_redshift_client, event)) evaluations.extend(assess_elb_v2_ssl_enforcement(aws_elb_v2_client, event)) + evaluations.extend(assess_elb_v1_ssl_enforcement(aws_elb_v1_client, event)) evaluations.extend(assess_rest_api_stages_ssl_enforcement(aws_api_gw_client, event)) evaluations.extend(assess_open_search_node_to_node_ssl_enforcement(aws_open_search_client, event)) evaluations.extend(assess_cloud_front_ssl_enforcement(aws_cloud_front_client, event)) diff --git a/src/lambda/gc12_check_private_marketplace/app.py b/src/lambda/gc12_check_private_marketplace/app.py index b5a77e4f..6d95fad7 100644 --- a/src/lambda/gc12_check_private_marketplace/app.py +++ b/src/lambda/gc12_check_private_marketplace/app.py @@ -30,17 +30,119 @@ logger.setLevel(logging.INFO) -def private_marketplace_is_configured(marketplace_catalog_client) -> bool: +def private_marketplace_is_configured(mgmt_account_id,execution_role_name,is_not_audit_account): + """ + Wrapper + """ + import os + import logging + import boto3 + import botocore.exceptions + logger = logging.getLogger() + logger.setLevel(logging.INFO) + os.environ["AWS_DEFAULT_REGION"] = "us-east-1" + # This gets the client after assuming the Config service role + # either in the same AWS account or cross-account. + def get_clientt( + service: str, + account_id: str | None = None, + role_name: str | None = None, + assume_role: bool = True, + region: str | None = None, + ): + """ + Return the service boto client. It should be used instead of directly calling the client. + This gets the client after assuming the Config service role for the provided account. + If no account_id or role_name is provided, the client is configured for the current credentials and account. + Keyword arguments: + service -- the service name used for calling the boto.client(service) + account_id -- the id of the account for the assumed role + role_name -- the name of the role to assume when creating the client + """ + # if not role_name or not account_id or not assume_role: + # return boto3.client(service) + credentials = get_assume_role_credentials(f"arn:aws:iam::{account_id}:role/{role_name}") + + return boto3.client( + service, + region_name="us-east-1", + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"], + ) + def get_assume_role_credentials(role_arn: str) -> dict: + """ + Returns the credentials required to assume the passed role. + Keyword arguments: + role_arn -- the arn of the role to assume + """ + sts_client = boto3.client("sts", region_name="us-east-1") + try: + assume_role_response = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="configLambdaExecution") + return assume_role_response["Credentials"] + except botocore.exceptions.ClientError as ex: + # Scrub error message for any internal account info leaks + if "AccessDenied" in ex.response["Error"]["Code"]: + ex.response["Error"]["Message"] = "AWS Config does not have permission to assume the IAM role." + else: + ex.response["Error"]["Message"] = "InternalError" + ex.response["Error"]["Code"] = "InternalError" + logger.error("ERROR assuming role. %s", ex.response["Error"]) + raise ex + def is_throttling_exception(e): + """Returns True if the exception code is one of the throttling exception codes we have""" + b_is_throttling = False + throttling_exception_codes = [ + "ConcurrentModificationException", + "InsufficientDeliveryPolicyException", + "NoAvailableDeliveryChannelException", + "ConcurrentModifications", + "LimitExceededException", + "OperationNotPermittedException", + "TooManyRequestsException", + "Throttling", + "ThrottlingException", + "InternalErrorException", + "InternalException", + "ECONNRESET", + "EPIPE", + "ETIMEDOUT", + ] + for throttling_code in throttling_exception_codes: + if throttling_code in e.response["Error"]["Code"]: + b_is_throttling = True + break + return b_is_throttling + """ + Wrapper + """ + marketplace_catalog_client = get_clientt( + "marketplace-catalog", + mgmt_account_id, + execution_role_name, + is_not_audit_account, + region="us-east-1" + ) + logger.info(marketplace_catalog_client.meta.region_name) + sts_client = get_client( + "sts", + mgmt_account_id, + execution_role_name, + is_not_audit_account, + region="us-east-1" + ) + identity = sts_client.get_caller_identity() + logger.info(identity) try: response = marketplace_catalog_client.list_entities( Catalog="AWSMarketplace", - EntityType="Experience", - FilterList=[{"Name": "Scope", "ValueList": ["SharedWithMe"]}], + EntityType="Experience" ) except botocore.exceptions.ClientError as err: raise ValueError(f"Error in AWS Marketplace Catalog: {err}") from err if not response: raise ValueError("No response from AWS Marketplace Catalog") + logger.info("Entities Returned: %s", response) entity_summary_list = response.get("EntitySummaryList") or [] for entity in entity_summary_list: if entity.get("EntityType") == "Experience": @@ -48,11 +150,17 @@ def private_marketplace_is_configured(marketplace_catalog_client) -> bool: return False -def policy_restricts_marketplace_access(iam_client, policy_content: str, interval_between_calls: float = 0.1) -> bool: +def policy_restricts_marketplace_access(iam_client, policy_content: str, interval_between_calls: str = "0.1") -> bool: args = { "PolicyInputList": [policy_content], - "ActionNames": ["aws-marketplace-management:*", "aws-marketplace:*"], + "ActionNames": ["aws-marketplace:As*", + "aws-marketplace:CreateP*", + "aws-marketplace:DescribePri*", + "aws-marketplace:Di*", + "aws-marketplace:ListP*", + "aws-marketplace:Start*"], } + resources: list[dict] = [] while True: response = iam_client.simulate_custom_policy(**args) @@ -64,7 +172,7 @@ def policy_restricts_marketplace_access(iam_client, policy_content: str, interva if not args.get("Marker"): break else: - time.sleep(interval_between_calls) + time.sleep(float(interval_between_calls)) for eval_result in resources: if eval_result.get("EvalDecision") == "allowed": return False @@ -72,7 +180,7 @@ def policy_restricts_marketplace_access(iam_client, policy_content: str, interva def get_policies_that_restrict_marketplace_access( - organizations_client, iam_client, interval_between_calls: float = 0.1 + organizations_client, iam_client, interval_between_calls: str = "0.1" ): policies = organizations_list_all_service_control_policies(organizations_client, interval_between_calls) selected_policy_summaries = [] @@ -95,7 +203,7 @@ def policy_is_attached( organizations_client, target_id: str, policy_ids: list[str], interval_between_calls: float = 0.1 ) -> bool: policies = organizations_list_all_policies_for_target( - organizations_client, target_id, interval_between_calls=interval_between_calls + organizations_client, target_id, interval_between_calls=float(interval_between_calls) ) logger.info("Policies found for target '%s': %s", target_id, policies) return any(x.get("Id") in policy_ids for x in policies) @@ -106,7 +214,7 @@ def is_policy_attached_in_ancestry(organizations_client, child_id: str, policy_i while True: if policy_is_attached(organizations_client, current_id, policy_ids): return True - parents = organizations_client.list_parents(ChildId=current_id).get("Parents", []) + parents = organizations_client.list_parents(ChildId=str(current_id)).get("Parents", []) if not parents: break parent_id = parents[0].get("Id") @@ -131,10 +239,14 @@ def assess_policy_attachment( "NON_COMPLIANT", "The restricting policy is attached to the Management Account, which is not allowed.", ) - ou_list = organizations_list_all_organizational_units(organizations_client, interval_between_calls) + parents = organizations_client.list_parents(ChildId=str(current_account_id)).get("Parents") + if not parents: + return False + parent_id = parents[0]["Id"] + ou_list = organizations_list_all_organizational_units(organizations_client, parent_id, interval_between_calls) missing_ous = [] for ou in ou_list: - ou_id = ou["Id"] + ou_id = str(ou["Id"]) if not policy_is_attached(organizations_client, ou_id, policy_ids, interval_between_calls): missing_ous.append(ou_id) if missing_ous: @@ -172,7 +284,7 @@ def lambda_handler(event, context): evaluation = build_evaluation(aws_account_id, "NOT_APPLICABLE", event, gr_requirement_type=gr_requirement_type) return submit_evaluations(aws_config_client, event, [evaluation]) restricting_policies = get_policies_that_restrict_marketplace_access( - aws_orgs_client, aws_iam_client, interval_between_calls + aws_orgs_client, aws_iam_client, float(interval_between_calls) ) if not restricting_policies: compliance_type = "NON_COMPLIANT" @@ -181,17 +293,22 @@ def lambda_handler(event, context): eval_ = build_evaluation(aws_account_id, compliance_type, event, annotation=annotation) return submit_evaluations(aws_config_client, event, [eval_]) compliance_type, annotation = assess_policy_attachment( - aws_orgs_client, restricting_policies, aws_account_id, interval_between_calls + aws_orgs_client, restricting_policies, aws_account_id, float(interval_between_calls) ) if compliance_type == "COMPLIANT": - aws_marketplace_catalog_client = get_client( - "marketplace-catalog", aws_account_id, execution_role_name, is_not_audit_account, region="us-east-1" - ) - if not private_marketplace_is_configured(aws_marketplace_catalog_client): + # aws_marketplace_catalog_client = get_client( + # "marketplace-catalog", aws_account_id, execution_role_name, is_not_audit_account, region='us-east-1' + # ) + + mgmt_account_id = get_organizations_mgmt_account_id(aws_orgs_client) + + if not private_marketplace_is_configured(mgmt_account_id,execution_role_name,is_not_audit_account): + # if not private_marketplace_is_configured(aws_marketplace_catalog_client): + compliance_type = "NON_COMPLIANT" annotation = "Private Marketplace NOT found." else: annotation = f"Private Marketplace found. {annotation}" logger.info(f"{compliance_type}: {annotation}") final_eval = build_evaluation(aws_account_id, compliance_type, event, annotation=annotation) - submit_evaluations(aws_config_client, event, [final_eval]) + submit_evaluations(aws_config_client, event, [final_eval]) \ No newline at end of file diff --git a/src/layer/cloud_guardrails/lib/python3.12/site-packages/boto_util/client.py b/src/layer/cloud_guardrails/lib/python3.12/site-packages/boto_util/client.py index 215219e7..a8c72bd7 100644 --- a/src/layer/cloud_guardrails/lib/python3.12/site-packages/boto_util/client.py +++ b/src/layer/cloud_guardrails/lib/python3.12/site-packages/boto_util/client.py @@ -1,12 +1,8 @@ import logging - import boto3 import botocore.exceptions - logger = logging.getLogger() logger.setLevel(logging.INFO) - - # This gets the client after assuming the Config service role # either in the same AWS account or cross-account. def get_client( @@ -15,38 +11,31 @@ def get_client( role_name: str | None = None, assume_role: bool = True, region: str | None = None, + endpoint_url: str | None = None, ): """ Return the service boto client. It should be used instead of directly calling the client. This gets the client after assuming the Config service role for the provided account. If no account_id or role_name is provided, the client is configured for the current credentials and account. - Keyword arguments: - service -- the service name used for calling the boto.client(service) - account_id -- the id of the account for the assumed role - role_name -- the name of the role to assume when creating the client """ if not role_name or not account_id or not assume_role: - return boto3.client(service) - + return boto3.client(service,endpoint_url=endpoint_url) credentials = get_assume_role_credentials(f"arn:aws:iam::{account_id}:role/{role_name}", region) return boto3.client( service, + endpoint_url=endpoint_url, aws_access_key_id=credentials["AccessKeyId"], aws_secret_access_key=credentials["SecretAccessKey"], aws_session_token=credentials["SessionToken"], ) - - def get_assume_role_credentials(role_arn: str, region: str = None) -> dict: """ Returns the credentials required to assume the passed role. - Keyword arguments: - role_arn -- the arn of the role to assume """ sts_client = boto3.client("sts", region_name=region) if region else boto3.client("sts") @@ -62,8 +51,6 @@ def get_assume_role_credentials(role_arn: str, region: str = None) -> dict: ex.response["Error"]["Code"] = "InternalError" logger.error("ERROR assuming role. %s", ex.response["Error"]) raise ex - - def is_throttling_exception(e): """Returns True if the exception code is one of the throttling exception codes we have""" b_is_throttling = False @@ -83,10 +70,8 @@ def is_throttling_exception(e): "EPIPE", "ETIMEDOUT", ] - for throttling_code in throttling_exception_codes: if throttling_code in e.response["Error"]["Code"]: b_is_throttling = True break - - return b_is_throttling + return b_is_throttling \ No newline at end of file