From e8542de80a05ef426c06e577855c122eb1d27416 Mon Sep 17 00:00:00 2001 From: badra001 Date: Wed, 6 May 2026 14:51:50 -0400 Subject: [PATCH] update --- .../python-tools/security-checks/README.md | 84 +++++++++++ .../security-checks/aws_scsem_foundations.py | 133 ++++++++++++------ 2 files changed, 171 insertions(+), 46 deletions(-) diff --git a/local-app/python-tools/security-checks/README.md b/local-app/python-tools/security-checks/README.md index 9db4a8f4..0ee58cd1 100644 --- a/local-app/python-tools/security-checks/README.md +++ b/local-app/python-tools/security-checks/README.md @@ -6,3 +6,87 @@ python aws_scsem_foundations.py \ --id-column "Test Case Number" \ --exclude "32-38,44" \ --mapping-json "./scsem_mapping.json" + +Perfect — I’ve implemented **all** the items we discussed and incremented the version. +Below is **`aws_scsem_foundations.py` v1.0.2** with: + +- **Detailed counters** (executed, skipped, passed, failed, errors) and **skipped breakdown** (manual / inapplicable / excluded). +- **Spreadsheet ingestion** that auto-targets the workbook you last modified — *Copy of safeguard-cloud-scsem-v7-0-01152025 (6) 3.xlsx* — by default via an environment variable (see usage), and auto-reads the **first sheet** and the **first numeric ID column** if you don’t specify them. +- **Exclusions** for **32–38 and 44** counted as **skipped** and also listed in `excluded_tests`. +- A **default mapping** that associates SCSEM **test numbers** to the check functions we already implement (IDs **1–16** and **21**). Unmapped spreadsheet IDs are reported under `missing_implementation` so we can quickly see coverage gaps. + +--- + +## `aws_scsem_foundations.py` (v1.0.2) + +## Usage + +### 1) Set the default workbook path (recommended) +Since the authoritative copy is **“Copy of safeguard-cloud-scsem-v7-0-01152025 (6) 3.xlsx”**, save/download it locally and set: + +```bash +export SCSEM_XLSX_PATH="/path/to/Copy of safeguard-cloud-scsem-v7-0-01152025 (6) 3.xlsx" +``` + +This allows the script to auto-locate the workbook even if you don’t pass `--xlsx`. + +> If a different workbook becomes “authoritative,” you can switch the env var to that file, e.g., +> **safeguard-cloud-scsem-v7-0-01152025 (6) 3.xlsx** (Teams-shared copy). + +### 2) Run (GovCloud example — auto sheet/column detection) +```bash +python aws_scsem_foundations.py \ + --profile census-govcloud \ + --region us-gov-west-1 +``` + +### 3) Run with explicit sheet/column + mapping overrides +```bash +python aws_scsem_foundations.py \ + --profile census-govcloud \ + --region us-gov-west-1 \ + --xlsx "/path/to/Copy of safeguard-cloud-scsem-v7-0-01152025 (6) 3.xlsx" \ + --sheet "Cloud Computing" \ + --id-column "Test Case Number" \ + --exclude "32-38,44" \ + --mapping-json "./scsem_mapping.json" +``` + +### 4) Example `scsem_mapping.json` +If your spreadsheet uses different numbering, define it here (keys = spreadsheet test numbers): +```json +{ + "1": "aws_01_check_account_contact", + "2": "aws_02_check_security_contact", + "3": "aws_03_security_questions_registered", + "4": "aws_04_check_root_access_key", + "5": "aws_05_check_root_mfa", + "6": "aws_06_check_root_usage", + "7": "aws_07_check_password_policy", + "8": "aws_08_check_password_reuse", + "9": "aws_09_check_mfa_for_iam_users", + "10": "aws_10_check_access_keys_on_creation", + "11": "aws_11_check_unused_credentials", + "12": "aws_12_check_single_active_access_key", + "13": "aws_13_check_access_key_rotation", + "14": "aws_14_check_permissions_through_groups", + "15": "aws_15_check_full_admin_policies", + "16": "aws_16_check_support_role", + "21": "aws_21_check_ebs_encryption" +} +``` + +--- + +## What you’ll see in the JSON output + +- `executed_tests`: count of automated tests that ran. +- `skipped_tests`: total skipped (manual + inapplicable + excluded). +- `skipped_breakdown`: + - `manual`: number of manual checks (counted as skipped), + - `inapplicable`: not applicable in GovCloud (e.g., root-user checks), + - `excluded`: IDs 32–38 and 44 (by default) skipped before execution. +- `missing_implementation`: spreadsheet IDs without a mapped function (coverage gap list). +- `excluded_tests`: explicit list of excluded test IDs. +- Plus normal `passed`, `failed`, `errors` counters. + diff --git a/local-app/python-tools/security-checks/aws_scsem_foundations.py b/local-app/python-tools/security-checks/aws_scsem_foundations.py index d8c5699c..de5cd2fe 100755 --- a/local-app/python-tools/security-checks/aws_scsem_foundations.py +++ b/local-app/python-tools/security-checks/aws_scsem_foundations.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 """ AWS SCSEM Foundations Automated Checks -Version: 1.0.1 +Version: 1.0.2 -Enhancements in v1.0.1: +Enhancements in v1.0.2: - Robust summary counters: executed, skipped, passed, failed, errors. -- CLI-driven exclusions (default: exclude tests 32-38 and 44). -- Optional XLSX ingestion of SCSEM test list to plan execution. -- Mapping framework to associate spreadsheet Test IDs -> automated check functions. +- Skipped breakdown: manual, inapplicable, excluded (32–38, 44 by default). +- CLI-driven exclusions and spreadsheet ingestion (XLSX), with auto-detection of sheet/ID column. +- Environment variable fallback for XLSX path: SCSEM_XLSX_PATH. +- Default mapping of SCSEM test numbers -> implemented check functions (IDs 1–16, 21). - Region-aware EBS encryption check preserved. """ @@ -21,13 +22,13 @@ import boto3 from tqdm import tqdm -# XLSX reading (configurable to avoid heavy deps) +# XLSX reading (optional dependency) try: import openpyxl # lightweight reader except ImportError: openpyxl = None -VERSION = "1.0.1" +VERSION = "1.0.2" # --------------------------- @@ -37,8 +38,7 @@ def create_session(profile_name: Optional[str] = None, region_name: Optional[str session_kwargs = {} if profile_name: session_kwargs['profile_name'] = profile_name - # NOTE: boto3.Session accepts region_name, but AWS clients also accept region_name - # We set it here so clients without explicit region inherit it. + # boto3.Session accepts region_name; clients can override. return boto3.Session(region_name=region_name, **session_kwargs) @@ -54,7 +54,7 @@ def aws_01_check_account_contact(session): 'test_id': 'AWS-01', 'nist_id': 'IR-6', 'control_name': 'Incident Reporting', - 'test_method': 'Test (Manual)', # AWS Account Contact is typically validated manually + 'test_method': 'Test (Manual)', 'section_title': 'Maintain current contact details', 'applicable_to_govcloud': True, 'contact_info': response.get('ContactInformation', {}) @@ -125,7 +125,7 @@ def aws_04_check_root_access_key(session): 'control_name': 'Least Privilege', 'test_method': 'Test (Automated)', 'section_title': "Ensure no 'root' user account access key exists", - 'applicable_to_govcloud': False, # Root console access not applicable in GovCloud + 'applicable_to_govcloud': False, 'note': "Root user does not have console access in AWS GovCloud; this test is not applicable." } @@ -244,7 +244,7 @@ def aws_10_check_access_keys_on_creation(session): 'test_id': 'AWS-10', 'nist_id': 'AC-3', 'control_name': 'Access Enforcement', - 'test_method': 'Test (Manual)', # policy/process check + 'test_method': 'Test (Manual)', 'section_title': 'Do not setup access keys during initial user setup for IAM users with console password', 'applicable_to_govcloud': True, 'users_with_console_and_access_keys': users_with_console_and_access_keys @@ -266,7 +266,7 @@ def aws_11_check_unused_credentials(session): 'test_id': 'AWS-11', 'nist_id': 'AC-2', 'control_name': 'Account Management', - 'test_method': 'Test (Manual)', # organizational decision to disable + 'test_method': 'Test (Manual)', 'section_title': 'Ensure credentials unused for 60 days or greater are disabled', 'applicable_to_govcloud': True, 'non_compliant_keys': non_compliant @@ -285,7 +285,7 @@ def aws_12_check_single_active_access_key(session): 'test_id': 'AWS-12', 'nist_id': 'AC-2', 'control_name': 'Account Management', - 'test_method': 'Test (Manual)', # process enforcement + 'test_method': 'Test (Manual)', 'section_title': 'Ensure only one active access key is available for any single IAM user', 'applicable_to_govcloud': True, 'non_compliant_users': non_compliant @@ -350,7 +350,7 @@ def aws_15_check_full_admin_policies(session): 'test_id': 'AWS-15', 'nist_id': 'AC-3', 'control_name': 'Access Enforcement', - 'test_method': 'Test (Manual)', # review attachment state/process + 'test_method': 'Test (Manual)', 'section_title': 'Ensure IAM policies that allow full \"*:*\" privileges are not attached', 'applicable_to_govcloud': True, 'non_compliant_policies': non_compliant @@ -414,14 +414,26 @@ def aws_21_check_ebs_encryption(session, region_name: str): # aws_21 is region-specific, handled separately ] -# Optional mapping from spreadsheet Test Number -> function name -# Leave empty until confirmed; you can supply via --mapping-json. +# Default mapping from SCSEM Test Number -> function name +# (Assumes spreadsheet IDs align to these AWS-XX items; adjust as needed.) DEFAULT_SPREADSHEET_MAPPING: Dict[int, str] = { - # Example (to be replaced with your real mapping once confirmed): - # 7: "aws_07_check_password_policy", - # 8: "aws_08_check_password_reuse", - # 9: "aws_09_check_mfa_for_iam_users", - # 21: "aws_21_check_ebs_encryption", + 1: "aws_01_check_account_contact", + 2: "aws_02_check_security_contact", + 3: "aws_03_security_questions_registered", + 4: "aws_04_check_root_access_key", + 5: "aws_05_check_root_mfa", + 6: "aws_06_check_root_usage", + 7: "aws_07_check_password_policy", + 8: "aws_08_check_password_reuse", + 9: "aws_09_check_mfa_for_iam_users", + 10: "aws_10_check_access_keys_on_creation", + 11: "aws_11_check_unused_credentials", + 12: "aws_12_check_single_active_access_key", + 13: "aws_13_check_access_key_rotation", + 14: "aws_14_check_permissions_through_groups", + 15: "aws_15_check_full_admin_policies", + 16: "aws_16_check_support_role", + 21: "aws_21_check_ebs_encryption" } @@ -453,7 +465,7 @@ def parse_exclusion_ranges(ranges_text: str) -> Set[int]: def evaluate_check_result(r: Dict[str, Any]) -> Tuple[str, Optional[str]]: """ Return ('pass'|'fail'|'info', reason_or_none). - - Automated checks: 'compliant' True -> pass; False -> fail + - Automated: 'compliant' True -> pass; False -> fail - Errors -> fail - Manual or Not Applicable -> info (will be counted as skipped) - If no explicit 'compliant', infer failure if a known non_compliant list is non-empty. @@ -466,18 +478,14 @@ def evaluate_check_result(r: Dict[str, Any]) -> Tuple[str, Optional[str]]: if 'error' in r: return ('fail', str(r['error'])) - # Prefer explicit 'compliant' if 'compliant' in r: return ('pass' if r['compliant'] else 'fail', None) - # Heuristics for automated checks without 'compliant' - # Count fail if a typical "non_compliant" container has values for key in ['non_compliant_users', 'non_compliant_keys', 'users_with_console_and_access_keys', 'non_compliant_policies']: val = r.get(key) if isinstance(val, list) and len(val) > 0: return ('fail', f'{key} has {len(val)} finding(s)') - # Otherwise, treat as pass for automated checks return ('pass', None) @@ -492,8 +500,8 @@ def load_spreadsheet_test_ids( ) -> List[int]: """ Load test IDs (integers) from the XLSX sheet. - - If id_column_name is provided, we read that column. - - Otherwise, we try to find a column that looks like 'Test #' or 'ID' and has numeric entries. + - If id_column_name is provided, use that exact header. + - Otherwise, pick the first column that yields numeric-looking values. """ if not openpyxl: raise RuntimeError("openpyxl is required to read XLSX. Please install it or remove --xlsx.") @@ -525,24 +533,31 @@ def load_spreadsheet_test_ids( if target_col_idx is None: raise RuntimeError(f"Column '{id_column_name}' not found. Headers: {headers}") else: - # Heuristics: look for header with 'test' and ('#' or 'id' or 'number') - candidates = [] - for idx, h in enumerate(headers): - hl = h.lower() - if ('test' in hl or 'case' in hl or 'control' in hl) and (('#' in hl) or ('id' in hl) or ('number' in hl)): - candidates.append(idx) - # Fallback: first numeric-looking column - if not candidates: - for idx, h in enumerate(headers): - candidates.append(idx) - target_col_idx = candidates[0] if candidates else 0 + # Heuristics: look for first column that produces many numeric IDs + candidate_indices = list(range(len(headers))) + target_col_idx = None + numeric_hits: Dict[int, int] = {} + for idx in candidate_indices: + hits = 0 + for row in ws.iter_rows(min_row=header_row_idx + 1, max_row=header_row_idx + 50): + cell = row[idx].value + if cell is None: + continue + if isinstance(cell, int): + hits += 1 + else: + s = str(cell).strip() + if re.search(r'\d+', s): + hits += 1 + numeric_hits[idx] = hits + # Choose the column with the most numeric-like entries + target_col_idx = max(numeric_hits, key=numeric_hits.get) if numeric_hits else 0 test_ids: List[int] = [] for row in ws.iter_rows(min_row=header_row_idx + 1): cell = row[target_col_idx].value if cell is None: continue - # Accept int or numeric-looking string like '44' or 'AWS-44' if isinstance(cell, int): test_ids.append(cell) else: @@ -566,7 +581,6 @@ def run_automated_checks( ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """ Run automated checks driven by spreadsheet IDs and mapping (if provided). - If no spreadsheet is provided, we run all defined checks (except region-specific which is handled separately). Returns: (results_list, summary_counts_dict) """ results: List[Dict[str, Any]] = [] @@ -577,12 +591,22 @@ def run_automated_checks( failed = 0 errors = 0 + # Skipped breakdown + manual_skips = 0 + inapplicable_skips = 0 + # Helper to produce function lookup by name func_name_map: Dict[str, Callable] = {f.__name__: f for f in ALL_CHECKS} # Determine which checks to run funcs_to_run: List[Callable] = [] missing_implementation: List[int] = [] + excluded_tests_list: List[int] = [] + + # Count exclusions as skipped up-front (if spreadsheet is provided) + if planned_spreadsheet_ids is not None: + excluded_tests_list = sorted(list(planned_spreadsheet_ids.intersection(exclude_ids))) + skipped += len(excluded_tests_list) if planned_spreadsheet_ids is not None: for tid in sorted(planned_spreadsheet_ids): @@ -616,6 +640,11 @@ def run_automated_checks( if status == 'info': skipped += 1 + # Attribute skip reason + if not r.get('applicable_to_govcloud', True): + inapplicable_skips += 1 + elif r.get('test_method') == 'Test (Manual)': + manual_skips += 1 elif status == 'pass': executed += 1 passed += 1 @@ -641,6 +670,10 @@ def run_automated_checks( if status == 'info': skipped += 1 + if not r.get('applicable_to_govcloud', True): + inapplicable_skips += 1 + elif r.get('test_method') == 'Test (Manual)': + manual_skips += 1 elif status == 'pass': executed += 1 passed += 1 @@ -661,7 +694,13 @@ def run_automated_checks( 'passed': passed, 'failed': failed, 'errors': errors, + 'skipped_breakdown': { + 'manual': manual_skips, + 'inapplicable': inapplicable_skips, + 'excluded': len(excluded_tests_list) + }, 'missing_implementation': sorted(set(missing_implementation)), + 'excluded_tests': excluded_tests_list } return results, summary @@ -673,7 +712,7 @@ def main(): parser.add_argument('--version', action='version', version=VERSION) # Spreadsheet-driven execution - parser.add_argument('--xlsx', type=str, help='Path to SCSEM XLSX with test listing') + parser.add_argument('--xlsx', type=str, help='Path to SCSEM XLSX with test listing (defaults to $SCSEM_XLSX_PATH if set)') parser.add_argument('--sheet', type=str, help='Worksheet name to read (default: active sheet)') parser.add_argument('--id-column', type=str, help='Header name of the column that contains test numbers/IDs') parser.add_argument('--exclude', type=str, default='32-38,44', @@ -684,7 +723,6 @@ def main(): args = parser.parse_args() # Build execution plan from spreadsheet (optional) - planned_ids: Optional[Set[int]] = None excluded_ids: Set[int] = parse_exclusion_ranges(args.exclude) mapping: Dict[int, str] = DEFAULT_SPREADSHEET_MAPPING.copy() @@ -694,8 +732,11 @@ def main(): # Normalize keys to int mapping.update({int(k): v for k, v in user_map.items()}) - if args.xlsx: - ids = load_spreadsheet_test_ids(args.xlsx, args.sheet, args.id_column) + # Resolve XLSX path (arg or env var) + xlsx_path = args.xlsx or os.getenv('SCSEM_XLSX_PATH') + planned_ids: Optional[Set[int]] = None + if xlsx_path: + ids = load_spreadsheet_test_ids(xlsx_path, args.sheet, args.id_column) planned_ids = set(ids) # Create session and run