From 5bb5b2d8e78997ca3c82a86d86b5d5b6a562ad59 Mon Sep 17 00:00:00 2001 From: KK Mookhey Date: Fri, 15 May 2026 07:44:25 -0700 Subject: [PATCH 1/2] chore(ci): unblock CI install + delegate line-length to ruff format The integrity + full-suite jobs have been failing since at least May 6 because `pip install -e ".[dev]"` builds pycairo from source, and the runner doesn't have the cairo headers installed. The lint job has been failing on 1,000+ E501 line-too-long warnings against long string literals that `ruff format` deliberately doesn't break. - workflow: install libcairo2-dev + pkg-config + python3-dev before pip install on both Python jobs so pycairo can build - workflow: install [dev,azure,gcp,voice] extras (not just [dev]) so Azure/GCP/voice tests can import their SDKs during pytest collection - ruff: ignore E501 in lint; `ruff format` already enforces line length where it can, and flagging long strings the formatter chose to keep intact just produces noise Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/integrity.yml | 26 ++++++++++++++++++++++---- pyproject.toml | 6 ++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integrity.yml b/.github/workflows/integrity.yml index 3b6e954..d37665a 100644 --- a/.github/workflows/integrity.yml +++ b/.github/workflows/integrity.yml @@ -39,10 +39,19 @@ jobs: python-version: "3.12" cache: pip - - name: Install package + dev extras + - name: Install system dependencies for pycairo / weasyprint + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev pkg-config python3-dev + + - name: Install package + all extras + # Install every optional extra so the full suite can exercise Azure, + # GCP, and voice tests without ModuleNotFoundError. The smoke jobs + # also need this because pytest collection imports test files, and + # voice/azure/gcp tests import their respective SDKs at module scope. run: | python -m pip install --upgrade pip - pip install -e ".[dev]" + pip install -e ".[dev,azure,gcp,voice]" - name: Doc-vs-code drift integrity tests # The single most load-bearing test in the repo. Catches the @@ -86,10 +95,19 @@ jobs: python-version: "3.12" cache: pip - - name: Install package + dev extras + - name: Install system dependencies for pycairo / weasyprint + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev pkg-config python3-dev + + - name: Install package + all extras + # Install every optional extra so the full suite can exercise Azure, + # GCP, and voice tests without ModuleNotFoundError. The smoke jobs + # also need this because pytest collection imports test files, and + # voice/azure/gcp tests import their respective SDKs at module scope. run: | python -m pip install --upgrade pip - pip install -e ".[dev]" + pip install -e ".[dev,azure,gcp,voice]" - name: Run full pytest suite run: pytest -q --ignore=tests/integration diff --git a/pyproject.toml b/pyproject.toml index 3dc46c3..8d11fe9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,3 +107,9 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] +# E501 (line-too-long) is delegated to `ruff format`. The formatter respects +# the 100-char limit where it can and leaves long string literals / f-strings +# intact rather than fragmenting them. Linting the same rule produces noise on +# strings the formatter chose not to break, so we ignore it here and let the +# formatter own line-length policy. +ignore = ["E501"] From 0a3861d43514178da8ea2959dc3bd7ad652ff395 Mon Sep 17 00:00:00 2001 From: KK Mookhey Date: Fri, 15 May 2026 07:44:25 -0700 Subject: [PATCH 2/2] style: ruff auto-fix + format + rename pre-existing N806/N803/E741 cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies `ruff check --fix --unsafe-fixes` and `ruff format` to clear the backlog of style violations that have accumulated in src/shasta/aws/ and src/shasta/azure/. Plus 8 small manual fixes the linter can't auto-resolve: - N806: rename function-local UPPER_CASE vars to lowercase (REQUIRED, FULL_SCOPE, DEFAULT_SA_SUFFIX, IMPERSONATION_ROLES, MIN_RETENTION_DAYS) — they aren't module constants and PEP 8 wants lowercase for function-scoped values - E741: rename ambiguous `l` to `listener` in the AWS pentest LB loop - N803: keep `keyTypes` parameter on two GCP-IAM mock side-effects (the name has to match the GCP API kwarg) and silence with `# noqa: N803` All 851 tests still pass locally after the auto-fix pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shasta/aws/ai_checks.py | 6 +- src/shasta/aws/ai_discovery.py | 8 +- src/shasta/aws/ai_sbom.py | 41 +- src/shasta/aws/backup.py | 88 +-- src/shasta/aws/client.py | 3 +- src/shasta/aws/cloudfront.py | 19 +- src/shasta/aws/cloudwatch_logs.py | 21 +- src/shasta/aws/compute.py | 166 +++-- src/shasta/aws/data_warehouse.py | 91 ++- src/shasta/aws/databases.py | 107 ++-- src/shasta/aws/encryption.py | 194 +++--- src/shasta/aws/iam.py | 114 ++-- src/shasta/aws/kms.py | 40 +- src/shasta/aws/logging_checks.py | 66 +- src/shasta/aws/networking.py | 61 +- src/shasta/aws/organizations.py | 60 +- src/shasta/aws/pentest.py | 8 +- src/shasta/aws/serverless.py | 183 +++--- src/shasta/aws/storage.py | 148 +++-- src/shasta/aws/vpc_endpoints.py | 21 +- src/shasta/aws/vulnerabilities.py | 6 +- src/shasta/azure/ai_checks.py | 3 +- src/shasta/azure/appservice.py | 176 +++--- src/shasta/azure/backup.py | 176 +++--- src/shasta/azure/databases.py | 242 +++---- src/shasta/azure/diagnostic_settings.py | 22 +- src/shasta/azure/encryption.py | 110 ++-- src/shasta/azure/governance.py | 88 +-- src/shasta/azure/iam.py | 22 +- src/shasta/azure/networking.py | 44 +- src/shasta/azure/private_endpoints.py | 154 ++--- src/shasta/azure/storage.py | 23 +- src/shasta/compliance/_status.py | 4 +- src/shasta/compliance/ai/mapper.py | 2 +- src/shasta/compliance/ai/nist_ai_rmf.py | 8 +- src/shasta/compliance/ai/scorer.py | 2 +- src/shasta/compliance/framework.py | 4 +- src/shasta/compliance/hipaa.py | 4 +- src/shasta/compliance/iso27001.py | 4 +- src/shasta/compliance/testing.py | 10 +- src/shasta/dashboard/__main__.py | 4 +- src/shasta/evidence/collector.py | 4 +- src/shasta/evidence/models.py | 21 +- src/shasta/gcp/client.py | 4 +- src/shasta/gcp/cloud_run.py | 28 +- src/shasta/gcp/compute.py | 50 +- src/shasta/gcp/encryption.py | 59 +- src/shasta/gcp/iam.py | 10 +- src/shasta/gcp/logging_checks.py | 97 ++- src/shasta/gcp/networking.py | 71 ++- src/shasta/gcp/storage.py | 2 +- src/shasta/integrations/github.py | 3 +- src/shasta/integrations/jira.py | 10 +- src/shasta/integrations/slack.py | 5 +- src/shasta/policies/generator.py | 2 +- src/shasta/remediation/engine.py | 280 +++++---- src/shasta/reports/generator.py | 9 +- src/shasta/reports/hipaa_report.py | 4 +- src/shasta/reports/iso27001_report.py | 4 +- src/shasta/reports/multi_framework_html.py | 13 +- src/shasta/reports/summary.py | 1 - src/shasta/sbom/discovery.py | 9 +- src/shasta/sbom/vuln_scanner.py | 9 +- src/shasta/threat_intel/advisory.py | 15 +- src/shasta/trustcenter/config.py | 1 - src/shasta/trustcenter/generator.py | 16 +- src/shasta/voice/__main__.py | 1 + src/shasta/voice/app.py | 1 + src/shasta/voice/cli.py | 7 +- src/shasta/voice/models.py | 1 + src/shasta/voice/observability.py | 1 + src/shasta/voice/realtime_config.py | 119 +++- src/shasta/voice/session.py | 6 +- src/shasta/voice/store.py | 124 ++-- src/shasta/voice/tools/controls.py | 10 +- src/shasta/voice/tools/findings.py | 10 +- src/shasta/voice/tools/risks.py | 34 +- src/shasta/voice/tools/router.py | 110 +++- src/shasta/voice/tools/scans.py | 1 + src/shasta/voice/tools/scores.py | 13 +- src/shasta/workflows/access_review.py | 4 +- src/shasta/workflows/drift.py | 8 +- src/shasta/workflows/risk_register.py | 10 +- tests/test_aws/test_aws_checks_functional.py | 35 +- tests/test_aws/test_aws_sweep_smoke.py | 1 - tests/test_aws/test_client.py | 3 +- tests/test_aws/test_models.py | 1 - tests/test_azure/conftest.py | 2 +- .../test_azure_checks_functional.py | 4 - tests/test_azure/test_smoke.py | 1 - tests/test_compliance/test_hipaa_generator.py | 4 +- tests/test_compliance/test_iso27001_scorer.py | 9 +- tests/test_compliance/test_mapper.py | 8 +- tests/test_compliance/test_scorer.py | 8 +- tests/test_compliance/test_status_walker.py | 4 +- tests/test_gcp/conftest.py | 4 +- tests/test_gcp/test_gcp_checks_functional.py | 594 ++++++++++-------- tests/test_gcp/test_smoke.py | 1 - tests/test_integrity/test_doc_claims.py | 12 +- tests/test_reports/test_report_generation.py | 3 +- tests/test_trustcenter/test_generator.py | 3 - tests/test_whitney/test_ai_policies.py | 14 +- tests/test_whitney/test_ai_sbom.py | 17 +- tests/test_whitney/test_eu_ai_act.py | 2 +- tests/test_whitney/test_integrity.py | 27 +- tests/test_whitney/test_iso42001.py | 2 +- tests/test_whitney/test_mapper.py | 9 +- tests/test_whitney/test_mitre_atlas.py | 4 +- tests/test_whitney/test_nist_ai_600_1.py | 4 +- tests/test_whitney/test_nist_ai_rmf.py | 6 +- tests/test_whitney/test_owasp_agentic.py | 2 +- tests/test_whitney/test_owasp_llm_top10.py | 2 +- tests/test_whitney/test_scorer.py | 8 +- tests/test_workflows/test_drift.py | 10 +- tests/test_workflows/test_risk_register.py | 19 +- tests/voice/conftest.py | 172 +++-- tests/voice/test_cli.py | 14 +- tests/voice/test_models.py | 13 +- tests/voice/test_observability.py | 7 +- tests/voice/test_realtime_config.py | 17 +- tests/voice/test_store_reads.py | 1 - tests/voice/test_store_writes.py | 28 +- tests/voice/test_tool_endpoints.py | 17 +- tests/voice/test_tools_risks.py | 33 +- 124 files changed, 2651 insertions(+), 2214 deletions(-) diff --git a/src/shasta/aws/ai_checks.py b/src/shasta/aws/ai_checks.py index 7cfa008..547bc48 100644 --- a/src/shasta/aws/ai_checks.py +++ b/src/shasta/aws/ai_checks.py @@ -768,9 +768,7 @@ def check_sagemaker_endpoint_encryption( ep_config_name = detail.get("EndpointConfigName") if ep_config_name: try: - config_detail = sm.describe_endpoint_config( - EndpointConfigName=ep_config_name - ) + config_detail = sm.describe_endpoint_config(EndpointConfigName=ep_config_name) kms_key = config_detail.get("KmsKeyId") except ClientError: pass @@ -1698,7 +1696,7 @@ def check_cloudtrail_ai_events(client: AWSClient, account_id: str, region: str) trails_with_ai_events: list[str] = [] for trail in trails: - trail_arn = trail.get("TrailARN", "") + trail.get("TrailARN", "") trail_name = trail.get("Name", "unknown") try: # Check event selectors (basic) diff --git a/src/shasta/aws/ai_discovery.py b/src/shasta/aws/ai_discovery.py index 99ddcf4..fe41c95 100644 --- a/src/shasta/aws/ai_discovery.py +++ b/src/shasta/aws/ai_discovery.py @@ -51,7 +51,13 @@ def discover_aws_ai_services(client: AWSClient) -> dict[str, Any]: regions = [default_region] results: dict[str, Any] = { - "sagemaker": {"available": False, "endpoints": [], "training_jobs": [], "models": [], "total_resources": 0}, + "sagemaker": { + "available": False, + "endpoints": [], + "training_jobs": [], + "models": [], + "total_resources": 0, + }, "bedrock": {"available": False, "models": [], "total_resources": 0}, "comprehend": {"available": False, "endpoints": [], "total_resources": 0}, "lambda_ai": {"available": False, "functions": [], "total_resources": 0}, diff --git a/src/shasta/aws/ai_sbom.py b/src/shasta/aws/ai_sbom.py index 3bfdf3f..31a1af1 100644 --- a/src/shasta/aws/ai_sbom.py +++ b/src/shasta/aws/ai_sbom.py @@ -13,8 +13,8 @@ import re from dataclasses import dataclass, field -from datetime import datetime, timezone -from enum import Enum +from datetime import UTC, datetime +from enum import StrEnum from pathlib import Path # --------------------------------------------------------------------------- @@ -30,9 +30,18 @@ EXCLUDED_PATH_SEGMENTS: frozenset[str] = frozenset( { - "node_modules", ".git", "__pycache__", ".venv", "venv", - ".tox", ".mypy_cache", ".pytest_cache", "dist", "build", - ".egg-info", ".eggs", + "node_modules", + ".git", + "__pycache__", + ".venv", + "venv", + ".tox", + ".mypy_cache", + ".pytest_cache", + "dist", + "build", + ".egg-info", + ".eggs", } ) @@ -41,8 +50,7 @@ ) ALL_SCANNABLE_EXTENSIONS: frozenset[str] = SOURCE_CODE_EXTENSIONS | frozenset( - {".yaml", ".yml", ".json", ".toml", ".cfg", ".ini", - ".txt", ".md", ".env", ".sh", ".bash"} + {".yaml", ".yml", ".json", ".toml", ".cfg", ".ini", ".txt", ".md", ".env", ".sh", ".bash"} ) # Vulnerable SDK versions for SBOM vulnerability checks. @@ -82,9 +90,7 @@ ] # Models with date suffixes (gpt-4-0613, claude-3-5-sonnet-20240620) are pinned. -PINNED_MODEL_PATTERN: re.Pattern[str] = re.compile( - r"""model\s*=\s*["'][a-z0-9-]+-\d{4,8}["']""" -) +PINNED_MODEL_PATTERN: re.Pattern[str] = re.compile(r"""model\s*=\s*["'][a-z0-9-]+-\d{4,8}["']""") def _iter_files( @@ -136,9 +142,7 @@ def _parse_requirements_txt(content: str) -> dict[str, str]: for sep in ("==", ">=", "<=", "~=", "!="): if sep in line: name, version = line.split(sep, 1) - deps[name.strip().lower()] = ( - version.strip().split(",")[0].split(";")[0].strip() - ) + deps[name.strip().lower()] = version.strip().split(",")[0].split(";")[0].strip() break return deps @@ -146,9 +150,7 @@ def _parse_requirements_txt(content: str) -> dict[str, str]: def _parse_pyproject_toml(content: str) -> dict[str, str]: """Best-effort extraction of dependencies from pyproject.toml.""" deps: dict[str, str] = {} - for m in re.finditer( - r'"([a-zA-Z0-9_-]+)\s*([><=!~]+)\s*([0-9][0-9a-zA-Z.]*)"', content - ): + for m in re.finditer(r'"([a-zA-Z0-9_-]+)\s*([><=!~]+)\s*([0-9][0-9a-zA-Z.]*)"', content): deps[m.group(1).lower()] = m.group(3) return deps @@ -192,6 +194,7 @@ def _version_matches_constraint(version_str: str, constraint: str) -> bool: except (ValueError, TypeError): return False + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -248,7 +251,7 @@ def _version_matches_constraint(version_str: str, constraint: str) -> bool: # --------------------------------------------------------------------------- -class AIComponentType(str, Enum): +class AIComponentType(StrEnum): """Type of AI component in the inventory.""" SDK = "sdk" @@ -481,7 +484,7 @@ def scan_azure_for_ai_components(client: object) -> list[AIComponent]: # Azure OpenAI deployments for dep in inventory.get("azure_openai", {}).get("deployments", []): name = dep.get("name", "") - model = dep.get("model", "") + dep.get("model", "") if name: components.append( AIComponent( @@ -580,7 +583,7 @@ def generate_ai_sbom( Returns a dict matching the CycloneDX 1.5 specification, with Whitney-specific properties on each component. """ - now = datetime.now(timezone.utc) + now = datetime.now(UTC) timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") # Map component types to CycloneDX types diff --git a/src/shasta/aws/backup.py b/src/shasta/aws/backup.py index 598656e..d529920 100644 --- a/src/shasta/aws/backup.py +++ b/src/shasta/aws/backup.py @@ -6,8 +6,6 @@ from __future__ import annotations -from typing import Any - from botocore.exceptions import ClientError from shasta.aws.client import AWSClient @@ -63,15 +61,17 @@ def check_backup_cross_region_copy( bk = client.client("backup") plans_list = bk.list_backup_plans().get("BackupPlansList", []) except ClientError as e: - return [Finding.not_assessed( - check_id="aws-backup-cross-region-copy", - title="Unable to check Backup cross-region copy", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::Backup::BackupPlan", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="aws-backup-cross-region-copy", + title="Unable to check Backup cross-region copy", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::Backup::BackupPlan", + account_id=account_id, + region=region, + ) + ] if not plans_list: return [] @@ -165,15 +165,17 @@ def check_backup_vault_access_policy( try: bk = client.client("backup") except ClientError as e: - return [Finding.not_assessed( - check_id="aws-backup-vault-access-policy", - title="Unable to check Backup vault access policies", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::Backup::BackupVault", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="aws-backup-vault-access-policy", + title="Unable to check Backup vault access policies", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::Backup::BackupVault", + account_id=account_id, + region=region, + ) + ] for v in _list_vaults(client): name = v.get("BackupVaultName", "unknown") arn = v.get("BackupVaultArn", "") @@ -226,9 +228,9 @@ def check_backup_vault_access_policy( account_id=account_id, remediation=( f"aws backup put-backup-vault-access-policy --backup-vault-name {name} " - '--policy file://deny-policy.json. The policy should Deny ' - 'backup:DeleteBackupVault, backup:DeleteRecoveryPoint, and ' - 'backup:StartCopyJob with a NotPrincipal of your break-glass role ARN.' + "--policy file://deny-policy.json. The policy should Deny " + "backup:DeleteBackupVault, backup:DeleteRecoveryPoint, and " + "backup:StartCopyJob with a NotPrincipal of your break-glass role ARN." ), soc2_controls=["A1.1", "A1.2"], cis_aws_controls=["2.x"], @@ -280,15 +282,17 @@ def check_backup_vault_lock(client: AWSClient, account_id: str, region: str) -> try: bk = client.client("backup") except ClientError as e: - return [Finding.not_assessed( - check_id="aws-backup-vault-lock", - title="Unable to check Backup vault lock status", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::Backup::BackupVault", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="aws-backup-vault-lock", + title="Unable to check Backup vault lock status", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::Backup::BackupVault", + account_id=account_id, + region=region, + ) + ] for v in _list_vaults(client): name = v.get("BackupVaultName", "unknown") arn = v.get("BackupVaultArn", "") @@ -430,15 +434,17 @@ def check_backup_plans(client: AWSClient, account_id: str, region: str) -> list[ bk = client.client("backup") plans = bk.list_backup_plans().get("BackupPlansList", []) except ClientError as e: - return [Finding.not_assessed( - check_id="aws-backup-plans", - title="Unable to check Backup plans", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::Backup::BackupPlan", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="aws-backup-plans", + title="Unable to check Backup plans", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::Backup::BackupPlan", + account_id=account_id, + region=region, + ) + ] if plans: return [ diff --git a/src/shasta/aws/client.py b/src/shasta/aws/client.py index 7075e8d..6a86a4f 100644 --- a/src/shasta/aws/client.py +++ b/src/shasta/aws/client.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from dataclasses import dataclass, field from typing import Any @@ -204,7 +203,7 @@ def get_enabled_regions(self) -> list[str]: except ClientError: return [self._region] - def for_region(self, region: str) -> "AWSClient": + def for_region(self, region: str) -> AWSClient: """Create a new AWSClient for a different region, reusing the same profile.""" return AWSClient(profile_name=self._profile_name, region=region) diff --git a/src/shasta/aws/cloudfront.py b/src/shasta/aws/cloudfront.py index 8663812..3d638b4 100644 --- a/src/shasta/aws/cloudfront.py +++ b/src/shasta/aws/cloudfront.py @@ -10,8 +10,6 @@ from __future__ import annotations -from typing import Any - from botocore.exceptions import ClientError from shasta.aws.client import AWSClient @@ -22,7 +20,6 @@ Severity, ) - # CloudFront is global. The structural smoke test honors this marker and # skips the multi-region iteration assertion. See ENGINEERING_PRINCIPLES.md #3. IS_GLOBAL = True @@ -70,9 +67,7 @@ def _list_distributions(client: AWSClient) -> list[dict]: return [] -def check_cloudfront_https_only( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_cloudfront_https_only(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS] CloudFront distributions must enforce HTTPS for viewer requests. The default cache behavior + every additional behavior must use either @@ -95,9 +90,7 @@ def check_cloudfront_https_only( cache_behaviors = (dist.get("CacheBehaviors", {}) or {}).get("Items", []) or [] weak_behaviors: list[dict] = [] if viewer_protocol == "allow-all": - weak_behaviors.append( - {"path": "default", "policy": viewer_protocol} - ) + weak_behaviors.append({"path": "default", "policy": viewer_protocol}) for cb in cache_behaviors: if cb.get("ViewerProtocolPolicy") == "allow-all": weak_behaviors.append( @@ -249,9 +242,7 @@ def check_cloudfront_min_tls_version( return findings -def check_cloudfront_waf_attached( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_cloudfront_waf_attached(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS] Public-facing CloudFront distributions should have a WAF Web ACL attached.""" distributions = _list_distributions(client) if not distributions: @@ -339,7 +330,9 @@ def check_cloudfront_geo_restrictions( "requirements, consider using geo restrictions to enforce them at the edge." ), severity=Severity.INFO, - status=ComplianceStatus.PASS if restriction_type != "none" else ComplianceStatus.PARTIAL, + status=ComplianceStatus.PASS + if restriction_type != "none" + else ComplianceStatus.PARTIAL, domain=CheckDomain.NETWORKING, resource_type="AWS::CloudFront::Distribution", resource_id=dist_arn, diff --git a/src/shasta/aws/cloudwatch_logs.py b/src/shasta/aws/cloudwatch_logs.py index 0b984d0..329e941 100644 --- a/src/shasta/aws/cloudwatch_logs.py +++ b/src/shasta/aws/cloudwatch_logs.py @@ -17,7 +17,6 @@ Severity, ) - # Log groups that don't need a retention policy (e.g. ephemeral / lambda streams) RETENTION_EXEMPT_PREFIXES = () @@ -52,15 +51,17 @@ def _check_region_log_groups(client: AWSClient, account_id: str, region: str) -> for page in paginator.paginate(): groups.extend(page.get("logGroups", [])) except ClientError as e: - return [Finding.not_assessed( - check_id="cwl-kms-encryption", - title="Unable to check CloudWatch log groups", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::Logs::LogGroup", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="cwl-kms-encryption", + title="Unable to check CloudWatch log groups", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::Logs::LogGroup", + account_id=account_id, + region=region, + ) + ] if not groups: return [] diff --git a/src/shasta/aws/compute.py b/src/shasta/aws/compute.py index 900d048..dbebb40 100644 --- a/src/shasta/aws/compute.py +++ b/src/shasta/aws/compute.py @@ -12,7 +12,7 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any from botocore.exceptions import ClientError @@ -25,7 +25,6 @@ Severity, ) - # Module-level marker so the structural multi-region smoke test knows # this module is regional, not global. See tests/test_aws/test_aws_sweep_smoke.py. IS_GLOBAL = False @@ -91,9 +90,7 @@ def _list_running_instances(ec2: Any) -> list[dict]: return out -def check_ec2_imdsv2_enforced( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_ec2_imdsv2_enforced(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS 5.6] EC2 instances must enforce IMDSv2 (HttpTokens=required). Capital One was breached in 2019 because IMDSv1 allowed an SSRF on a @@ -101,19 +98,20 @@ def check_ec2_imdsv2_enforced( credentials. IMDSv2 requires a session token (PUT method) which SSRF cannot trivially perform. """ - findings: list[Finding] = [] try: ec2 = client.client("ec2") except ClientError as e: - return [Finding.not_assessed( - check_id="ec2-imdsv2-enforced", - title="Unable to check EC2 IMDSv2 enforcement", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="AWS::EC2::Instance", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="ec2-imdsv2-enforced", + title="Unable to check EC2 IMDSv2 enforcement", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="AWS::EC2::Instance", + account_id=account_id, + region=region, + ) + ] instances = _list_running_instances(ec2) if not instances: @@ -132,11 +130,7 @@ def check_ec2_imdsv2_enforced( { "instance_id": instance_id, "name": next( - ( - t["Value"] - for t in (inst.get("Tags") or []) - if t.get("Key") == "Name" - ), + (t["Value"] for t in (inst.get("Tags") or []) if t.get("Key") == "Name"), "", ), "http_tokens": http_tokens, @@ -192,9 +186,7 @@ def check_ec2_imdsv2_enforced( ] -def check_ec2_public_ips( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_ec2_public_ips(client: AWSClient, account_id: str, region: str) -> list[Finding]: """EC2 instances with public IPv4 addresses should be inventoried. A public IP on an EC2 instance is not necessarily wrong, but it should be @@ -204,15 +196,17 @@ def check_ec2_public_ips( try: ec2 = client.client("ec2") except ClientError as e: - return [Finding.not_assessed( - check_id="ec2-public-ips", - title="Unable to check EC2 public IPs", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="AWS::EC2::Instance", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="ec2-public-ips", + title="Unable to check EC2 public IPs", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="AWS::EC2::Instance", + account_id=account_id, + region=region, + ) + ] instances = _list_running_instances(ec2) if not instances: @@ -227,11 +221,7 @@ def check_ec2_public_ips( "instance_id": inst.get("InstanceId", "unknown"), "public_ip": public_ip, "name": next( - ( - t["Value"] - for t in (inst.get("Tags") or []) - if t.get("Key") == "Name" - ), + (t["Value"] for t in (inst.get("Tags") or []) if t.get("Key") == "Name"), "", ), } @@ -295,15 +285,17 @@ def check_ec2_instance_profile_attached( try: ec2 = client.client("ec2") except ClientError as e: - return [Finding.not_assessed( - check_id="ec2-instance-profile", - title="Unable to check EC2 instance profiles", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="AWS::EC2::Instance", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="ec2-instance-profile", + title="Unable to check EC2 instance profiles", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="AWS::EC2::Instance", + account_id=account_id, + region=region, + ) + ] instances = _list_running_instances(ec2) if not instances: @@ -359,9 +351,7 @@ def check_ec2_instance_profile_attached( ] -def check_ami_age( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_ami_age(client: AWSClient, account_id: str, region: str) -> list[Finding]: """EC2 instances should run on AMIs younger than the threshold (default 90 days). Stale AMIs accumulate unpatched CVEs. The right pattern is rebuilding the @@ -370,15 +360,17 @@ def check_ami_age( try: ec2 = client.client("ec2") except ClientError as e: - return [Finding.not_assessed( - check_id="ec2-ami-age", - title="Unable to check AMI age", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="AWS::EC2::Image", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="ec2-ami-age", + title="Unable to check AMI age", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="AWS::EC2::Image", + account_id=account_id, + region=region, + ) + ] instances = _list_running_instances(ec2) if not instances: @@ -393,17 +385,19 @@ def check_ami_age( ami_resp = ec2.describe_images(ImageIds=list(ami_ids)) amis = {a["ImageId"]: a for a in ami_resp.get("Images", [])} except ClientError as e: - return [Finding.not_assessed( - check_id="ec2-ami-age", - title="Unable to check AMI age", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="AWS::EC2::Image", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="ec2-ami-age", + title="Unable to check AMI age", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="AWS::EC2::Image", + account_id=account_id, + region=region, + ) + ] - threshold = datetime.now(timezone.utc) - timedelta(days=AMI_AGE_DAYS_THRESHOLD) + threshold = datetime.now(UTC) - timedelta(days=AMI_AGE_DAYS_THRESHOLD) stale_amis: list[dict] = [] for ami_id, ami in amis.items(): creation = ami.get("CreationDate") @@ -419,11 +413,13 @@ def check_ami_age( "ami_id": ami_id, "name": ami.get("Name", ""), "created": creation, - "age_days": (datetime.now(timezone.utc) - created).days, + "age_days": (datetime.now(UTC) - created).days, } ) - instances_on_stale = sum(1 for i in instances if i.get("ImageId") in {a["ami_id"] for a in stale_amis}) + instances_on_stale = sum( + 1 for i in instances if i.get("ImageId") in {a["ami_id"] for a in stale_amis} + ) if not stale_amis: return [ @@ -491,9 +487,7 @@ def _list_eks_clusters(client: AWSClient) -> list[dict]: return [] -def check_eks_private_endpoint( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_eks_private_endpoint(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS 5.4.x] EKS API server endpoint should be private (or at least restricted). A public EKS API endpoint is reachable from anywhere on the internet. Even @@ -561,9 +555,7 @@ def check_eks_private_endpoint( return findings -def check_eks_audit_logging( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_eks_audit_logging(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS 5.4.x] EKS control plane logging should include audit + authenticator. Without these log types, you cannot reconstruct who did what in the cluster @@ -571,7 +563,7 @@ def check_eks_audit_logging( controllerManager / scheduler — all five are recommended for SOC 2. """ findings: list[Finding] = [] - REQUIRED = {"api", "audit", "authenticator"} + required = {"api", "audit", "authenticator"} for cluster in _list_eks_clusters(client): name = cluster.get("name", "unknown") @@ -582,7 +574,7 @@ def check_eks_audit_logging( if entry.get("enabled"): enabled_types.update(entry.get("types", [])) - missing = REQUIRED - enabled_types + missing = required - enabled_types if not missing: findings.append( Finding( @@ -634,9 +626,7 @@ def check_eks_audit_logging( return findings -def check_eks_secrets_encryption( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_eks_secrets_encryption(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS 5.4.x] EKS clusters should encrypt Kubernetes secrets with KMS. By default, Kubernetes secrets are stored base64-encoded (not encrypted) in @@ -648,9 +638,7 @@ def check_eks_secrets_encryption( name = cluster.get("name", "unknown") arn = cluster.get("arn", "") encryption_config = cluster.get("encryptionConfig") or [] - secrets_encrypted = any( - "secrets" in (e.get("resources") or []) for e in encryption_config - ) + secrets_encrypted = any("secrets" in (e.get("resources") or []) for e in encryption_config) if secrets_encrypted: findings.append( @@ -724,9 +712,7 @@ def _list_ecs_task_definitions(client: AWSClient) -> list[dict]: return [] -def check_ecs_task_privileged( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_ecs_task_privileged(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS 5.x] ECS task definitions should not run containers in privileged mode. Privileged containers can access the host kernel, mount filesystems, and @@ -796,9 +782,7 @@ def check_ecs_task_privileged( ] -def check_ecs_task_root_user( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_ecs_task_root_user(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS 5.x] ECS task definitions should not run containers as root (uid 0). Containers running as root that are exploited give the attacker root inside @@ -815,7 +799,7 @@ def check_ecs_task_root_user( for cd in td.get("containerDefinitions", []) or []: user = cd.get("user", "") # Empty string means root by default - is_root = (user == "" or user == "root" or user == "0" or user.startswith("0:")) + is_root = user == "" or user == "root" or user == "0" or user.startswith("0:") if is_root: root_running.append( { diff --git a/src/shasta/aws/data_warehouse.py b/src/shasta/aws/data_warehouse.py index a952731..b856409 100644 --- a/src/shasta/aws/data_warehouse.py +++ b/src/shasta/aws/data_warehouse.py @@ -9,8 +9,6 @@ from __future__ import annotations -from typing import Any - from botocore.exceptions import ClientError from shasta.aws.client import AWSClient @@ -21,7 +19,6 @@ Severity, ) - # Regional. Iterates client.get_enabled_regions() per Engineering Principle #3. IS_GLOBAL = False @@ -71,9 +68,7 @@ def _redshift_clusters(client: AWSClient) -> list[dict]: return [] -def check_redshift_encryption( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_redshift_encryption(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Redshift clusters must be encrypted at rest.""" findings: list[Finding] = [] for cluster in _redshift_clusters(client): @@ -127,9 +122,7 @@ def check_redshift_encryption( return findings -def check_redshift_public_access( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_redshift_public_access(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Redshift clusters should not be publicly accessible.""" findings: list[Finding] = [] for cluster in _redshift_clusters(client): @@ -182,23 +175,23 @@ def check_redshift_public_access( return findings -def check_redshift_audit_logging( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_redshift_audit_logging(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Redshift clusters should have audit logging enabled.""" findings: list[Finding] = [] try: rs = client.client("redshift") except ClientError as e: - return [Finding.not_assessed( - check_id="redshift-audit-logging", - title="Unable to check Redshift audit logging", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::Redshift::Cluster", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="redshift-audit-logging", + title="Unable to check Redshift audit logging", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::Redshift::Cluster", + account_id=account_id, + region=region, + ) + ] for cluster in _redshift_clusters(client): cid = cluster.get("ClusterIdentifier", "unknown") arn = f"arn:aws:redshift:{region}:{account_id}:cluster:{cid}" @@ -255,23 +248,23 @@ def check_redshift_audit_logging( return findings -def check_redshift_require_ssl( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_redshift_require_ssl(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Redshift cluster parameter group should set require_ssl=true.""" findings: list[Finding] = [] try: rs = client.client("redshift") except ClientError as e: - return [Finding.not_assessed( - check_id="redshift-require-ssl", - title="Unable to check Redshift SSL requirement", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::Redshift::Cluster", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="redshift-require-ssl", + title="Unable to check Redshift SSL requirement", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::Redshift::Cluster", + account_id=account_id, + region=region, + ) + ] for cluster in _redshift_clusters(client): cid = cluster.get("ClusterIdentifier", "unknown") @@ -285,7 +278,9 @@ def check_redshift_require_ssl( try: params_resp = rs.describe_cluster_parameters(ParameterGroupName=pg_name) for param in params_resp.get("Parameters", []): - if param.get("ParameterName") == "require_ssl" and param.get("ParameterValue") in ("true", "1"): + if param.get("ParameterName") == "require_ssl" and param.get( + "ParameterValue" + ) in ("true", "1"): require_ssl_set = True break except ClientError: @@ -465,9 +460,7 @@ def check_elasticache_encryption_at_rest( return findings -def check_elasticache_auth_token( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_elasticache_auth_token(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS] ElastiCache Redis with transit encryption should also have AUTH token enabled.""" findings: list[Finding] = [] for rg in _elasticache_clusters(client): @@ -529,23 +522,23 @@ def check_elasticache_auth_token( # --------------------------------------------------------------------------- -def check_neptune_encryption( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_neptune_encryption(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Neptune clusters must be encrypted at rest.""" try: neptune = client.client("neptune") clusters = neptune.describe_db_clusters().get("DBClusters", []) except ClientError as e: - return [Finding.not_assessed( - check_id="neptune-encryption", - title="Unable to check Neptune encryption", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::Neptune::DBCluster", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="neptune-encryption", + title="Unable to check Neptune encryption", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::Neptune::DBCluster", + account_id=account_id, + region=region, + ) + ] findings: list[Finding] = [] for cluster in clusters: diff --git a/src/shasta/aws/databases.py b/src/shasta/aws/databases.py index 9b03451..46dd63f 100644 --- a/src/shasta/aws/databases.py +++ b/src/shasta/aws/databases.py @@ -8,8 +8,6 @@ from __future__ import annotations -from typing import Any - from botocore.exceptions import ClientError from shasta.aws.client import AWSClient @@ -71,9 +69,7 @@ def _get_parameter_value(client: AWSClient, param_group_name: str, param_name: s return None -def check_rds_force_ssl_parameter( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_rds_force_ssl_parameter(client: AWSClient, account_id: str, region: str) -> list[Finding]: """PostgreSQL/SQL Server RDS parameter group should force SSL. Mirrors Azure's check_postgresql_secure_transport. Without this parameter, @@ -93,7 +89,14 @@ def check_rds_force_ssl_parameter( if not pg_name: continue - if engine in ("postgres", "aurora-postgresql", "sqlserver-ex", "sqlserver-se", "sqlserver-ee", "sqlserver-web"): + if engine in ( + "postgres", + "aurora-postgresql", + "sqlserver-ex", + "sqlserver-se", + "sqlserver-ee", + "sqlserver-web", + ): param_name = "rds.force_ssl" expected_value = "1" elif engine in ("mysql", "mariadb", "aurora-mysql"): @@ -148,7 +151,12 @@ def check_rds_force_ssl_parameter( ), soc2_controls=["CC6.1", "CC6.7"], cis_aws_controls=["2.3.x"], - details={"db": db_id, "engine": engine, "parameter": param_name, "value": actual}, + details={ + "db": db_id, + "engine": engine, + "parameter": param_name, + "value": actual, + }, ) ) return findings @@ -229,9 +237,7 @@ def check_rds_postgres_log_settings( return findings -def check_rds_min_tls_version( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_rds_min_tls_version(client: AWSClient, account_id: str, region: str) -> list[Finding]: """SQL Server RDS should have rds.tls_version set to TLS 1.2 or higher. Mirrors Azure's check_sql_min_tls. Only applies to SQL Server engines. @@ -550,15 +556,17 @@ def check_documentdb_encryption(client: AWSClient, account_id: str, region: str) docdb = client.client("docdb") clusters = docdb.describe_db_clusters().get("DBClusters", []) except ClientError as e: - return [Finding.not_assessed( - check_id="docdb-encryption", - title="Unable to check DocumentDB encryption", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::DocDB::DBCluster", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="docdb-encryption", + title="Unable to check DocumentDB encryption", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::DocDB::DBCluster", + account_id=account_id, + region=region, + ) + ] for cluster in clusters: if "docdb" not in (cluster.get("Engine") or ""): @@ -607,15 +615,17 @@ def check_documentdb_audit_logs(client: AWSClient, account_id: str, region: str) docdb = client.client("docdb") clusters = docdb.describe_db_clusters().get("DBClusters", []) except ClientError as e: - return [Finding.not_assessed( - check_id="docdb-audit-logs", - title="Unable to check DocumentDB audit logs", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::DocDB::DBCluster", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="docdb-audit-logs", + title="Unable to check DocumentDB audit logs", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::DocDB::DBCluster", + account_id=account_id, + region=region, + ) + ] for cluster in clusters: if "docdb" not in (cluster.get("Engine") or ""): @@ -672,20 +682,21 @@ def check_documentdb_audit_logs(client: AWSClient, account_id: str, region: str) def check_dynamodb_pitr(client: AWSClient, account_id: str, region: str) -> list[Finding]: """DynamoDB tables should have Point-in-Time Recovery enabled.""" - findings: list[Finding] = [] try: ddb = client.client("dynamodb") tables = ddb.list_tables().get("TableNames", []) except ClientError as e: - return [Finding.not_assessed( - check_id="dynamodb-pitr", - title="Unable to check DynamoDB PITR", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="AWS::DynamoDB::Table", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="dynamodb-pitr", + title="Unable to check DynamoDB PITR", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="AWS::DynamoDB::Table", + account_id=account_id, + region=region, + ) + ] no_pitr: list[str] = [] pitr: list[str] = [] @@ -757,15 +768,17 @@ def check_dynamodb_encryption_kms(client: AWSClient, account_id: str, region: st ddb = client.client("dynamodb") tables = ddb.list_tables().get("TableNames", []) except ClientError as e: - return [Finding.not_assessed( - check_id="dynamodb-kms", - title="Unable to check DynamoDB encryption", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::DynamoDB::Table", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="dynamodb-kms", + title="Unable to check DynamoDB encryption", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::DynamoDB::Table", + account_id=account_id, + region=region, + ) + ] for t in tables: try: diff --git a/src/shasta/aws/encryption.py b/src/shasta/aws/encryption.py index 9e2ff95..fb2eb61 100644 --- a/src/shasta/aws/encryption.py +++ b/src/shasta/aws/encryption.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import Any +from datetime import UTC from botocore.exceptions import ClientError @@ -60,15 +60,17 @@ def check_efs_encryption(client: AWSClient, account_id: str, region: str) -> lis efs = client.client("efs") fs_list = efs.describe_file_systems().get("FileSystems", []) except ClientError as e: - return [Finding.not_assessed( - check_id="efs-encryption", - title="Unable to check EFS encryption", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::EFS::FileSystem", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="efs-encryption", + title="Unable to check EFS encryption", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::EFS::FileSystem", + account_id=account_id, + region=region, + ) + ] for fs in fs_list: fs_id = fs.get("FileSystemId", "unknown") @@ -123,7 +125,6 @@ def check_efs_encryption(client: AWSClient, account_id: str, region: str) -> lis def check_sns_topic_encryption(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS] SNS topics should be encrypted at rest with KMS.""" - findings: list[Finding] = [] try: sns = client.client("sns") paginator = sns.get_paginator("list_topics") @@ -131,15 +132,17 @@ def check_sns_topic_encryption(client: AWSClient, account_id: str, region: str) for page in paginator.paginate(): topics.extend(t["TopicArn"] for t in page.get("Topics", [])) except ClientError as e: - return [Finding.not_assessed( - check_id="sns-encryption", - title="Unable to check SNS topic encryption", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::SNS::Topic", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="sns-encryption", + title="Unable to check SNS topic encryption", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::SNS::Topic", + account_id=account_id, + region=region, + ) + ] encrypted = 0 unencrypted: list[str] = [] @@ -202,20 +205,21 @@ def check_sns_topic_encryption(client: AWSClient, account_id: str, region: str) def check_sqs_queue_encryption(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS] SQS queues should be encrypted at rest with KMS or SSE.""" - findings: list[Finding] = [] try: sqs = client.client("sqs") queues = sqs.list_queues().get("QueueUrls", []) except ClientError as e: - return [Finding.not_assessed( - check_id="sqs-encryption", - title="Unable to check SQS queue encryption", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::SQS::Queue", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="sqs-encryption", + title="Unable to check SQS queue encryption", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::SQS::Queue", + account_id=account_id, + region=region, + ) + ] encrypted = 0 unencrypted: list[str] = [] @@ -280,7 +284,6 @@ def check_secrets_manager_rotation( client: AWSClient, account_id: str, region: str ) -> list[Finding]: """[CIS AWS] Secrets Manager secrets should have automatic rotation enabled.""" - findings: list[Finding] = [] try: sm = client.client("secretsmanager") paginator = sm.get_paginator("list_secrets") @@ -288,15 +291,17 @@ def check_secrets_manager_rotation( for page in paginator.paginate(): secrets.extend(page.get("SecretList", [])) except ClientError as e: - return [Finding.not_assessed( - check_id="secrets-manager-rotation", - title="Unable to check Secrets Manager rotation", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::SecretsManager::Secret", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="secrets-manager-rotation", + title="Unable to check Secrets Manager rotation", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::SecretsManager::Secret", + account_id=account_id, + region=region, + ) + ] if not secrets: return [] @@ -352,9 +357,8 @@ def check_acm_expiring_certificates( client: AWSClient, account_id: str, region: str ) -> list[Finding]: """[CIS AWS] ACM certificates expiring within 30 days should be flagged.""" - from datetime import datetime, timedelta, timezone + from datetime import datetime, timedelta - findings: list[Finding] = [] try: acm = client.client("acm") paginator = acm.get_paginator("list_certificates") @@ -362,20 +366,22 @@ def check_acm_expiring_certificates( for page in paginator.paginate(): certs.extend(page.get("CertificateSummaryList", [])) except ClientError as e: - return [Finding.not_assessed( - check_id="acm-expiring-certs", - title="Unable to check ACM certificates", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::CertificateManager::Certificate", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="acm-expiring-certs", + title="Unable to check ACM certificates", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::CertificateManager::Certificate", + account_id=account_id, + region=region, + ) + ] if not certs: return [] - threshold = datetime.now(timezone.utc) + timedelta(days=30) + threshold = datetime.now(UTC) + timedelta(days=30) expiring: list[dict] = [] for c in certs: not_after = c.get("NotAfter") @@ -569,7 +575,7 @@ def check_ebs_volumes(client: AWSClient, account_id: str, region: str) -> list[F resource_id=vol["volume_id"], region=region, account_id=account_id, - remediation=f"EBS volumes cannot be encrypted in-place. Create an encrypted snapshot, then create a new encrypted volume from it, and swap. Enable EBS encryption by default to prevent future unencrypted volumes.", + remediation="EBS volumes cannot be encrypted in-place. Create an encrypted snapshot, then create a new encrypted volume from it, and swap. Enable EBS encryption by default to prevent future unencrypted volumes.", soc2_controls=["CC6.7"], details=vol, ) @@ -593,15 +599,17 @@ def check_ebs_volumes(client: AWSClient, account_id: str, region: str) -> list[F ) except ClientError as e: - return [Finding.not_assessed( - check_id="ebs-volume-encrypted", - title="Unable to check EBS volume encryption", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::EC2::Volume", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="ebs-volume-encrypted", + title="Unable to check EBS volume encryption", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::EC2::Volume", + account_id=account_id, + region=region, + ) + ] return findings @@ -657,21 +665,23 @@ def check_rds_encryption(client: AWSClient, account_id: str, region: str) -> lis resource_id=db_arn, region=region, account_id=account_id, - remediation=f"RDS encryption cannot be enabled on an existing unencrypted instance. Create an encrypted snapshot, restore to a new encrypted instance, then switch over. Enable encryption for all new instances.", + remediation="RDS encryption cannot be enabled on an existing unencrypted instance. Create an encrypted snapshot, restore to a new encrypted instance, then switch over. Enable encryption for all new instances.", soc2_controls=["CC6.7"], details={"db_id": db_id, "engine": engine, "encrypted": False}, ) ) except ClientError as e: - return [Finding.not_assessed( - check_id="rds-encryption-at-rest", - title="Unable to check RDS encryption", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::RDS::DBInstance", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="rds-encryption-at-rest", + title="Unable to check RDS encryption", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::RDS::DBInstance", + account_id=account_id, + region=region, + ) + ] return findings @@ -713,15 +723,17 @@ def check_rds_public_access(client: AWSClient, account_id: str, region: str) -> ) ) except ClientError as e: - return [Finding.not_assessed( - check_id="rds-no-public-access", - title="Unable to check RDS public access", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::RDS::DBInstance", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="rds-no-public-access", + title="Unable to check RDS public access", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::RDS::DBInstance", + account_id=account_id, + region=region, + ) + ] return findings @@ -793,14 +805,16 @@ def check_rds_backups(client: AWSClient, account_id: str, region: str) -> list[F ) ) except ClientError as e: - return [Finding.not_assessed( - check_id="rds-backup-enabled", - title="Unable to check RDS backups", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::RDS::DBInstance", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="rds-backup-enabled", + title="Unable to check RDS backups", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::RDS::DBInstance", + account_id=account_id, + region=region, + ) + ] return findings diff --git a/src/shasta/aws/iam.py b/src/shasta/aws/iam.py index 4e39e9c..b35af27 100644 --- a/src/shasta/aws/iam.py +++ b/src/shasta/aws/iam.py @@ -8,7 +8,7 @@ from __future__ import annotations -from datetime import datetime, timezone, timedelta +from datetime import UTC, datetime, timedelta from typing import Any from shasta.aws.client import AWSClient @@ -224,7 +224,7 @@ def check_root_account_activity(iam: Any, account_id: str, region: str) -> list[ if not root_entry: return findings - now = datetime.now(timezone.utc) + now = datetime.now(UTC) threshold = now - timedelta(days=ROOT_RECENT_USE_DAYS) last_activity: datetime | None = None @@ -394,7 +394,7 @@ def check_access_key_rotation(iam: Any, account_id: str, region: str) -> list[Fi """CC6.3 — Check that access keys are rotated within the threshold.""" findings = [] users = _get_all_users(iam) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for user in users: username = user["UserName"] @@ -460,22 +460,24 @@ def check_access_key_rotation(iam: Any, account_id: str, region: str) -> list[Fi def check_inactive_users(iam: Any, account_id: str, region: str) -> list[Finding]: """CC6.3 — Check for users who haven't been active recently.""" findings = [] - now = datetime.now(timezone.utc) + now = datetime.now(UTC) threshold = now - timedelta(days=INACTIVE_USER_DAYS) try: _generate_credential_report(iam) report = _parse_credential_report(iam) except Exception as e: - return [Finding.not_assessed( - check_id="iam-inactive-user", - title="Unable to check inactive users", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="AWS::IAM::User", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="iam-inactive-user", + title="Unable to check inactive users", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="AWS::IAM::User", + account_id=account_id, + region=region, + ) + ] for entry in report: if entry["user"] == "": @@ -699,9 +701,7 @@ def _parse_credential_report(iam: Any) -> list[dict]: # --------------------------------------------------------------------------- -def check_iam_policy_wildcards( - iam: Any, account_id: str, region: str -) -> list[Finding]: +def check_iam_policy_wildcards(iam: Any, account_id: str, region: str) -> list[Finding]: """[CIS AWS 1.16] Customer-managed IAM policies should not grant Action='*' on Resource='*'. Mirrors Azure's check_custom_role_wildcards. A customer-managed policy @@ -717,15 +717,17 @@ def check_iam_policy_wildcards( for page in paginator.paginate(Scope="Local", OnlyAttached=False): custom_policies.extend(page.get("Policies", [])) except Exception as e: - return [Finding.not_assessed( - check_id="iam-policy-wildcards", - title="Unable to check IAM policy wildcards", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="AWS::IAM::ManagedPolicy", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="iam-policy-wildcards", + title="Unable to check IAM policy wildcards", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="AWS::IAM::ManagedPolicy", + account_id=account_id, + region=region, + ) + ] if not custom_policies: return [] @@ -823,9 +825,7 @@ def check_iam_policy_wildcards( ] -def check_iam_role_trust_external_account( - iam: Any, account_id: str, region: str -) -> list[Finding]: +def check_iam_role_trust_external_account(iam: Any, account_id: str, region: str) -> list[Finding]: """IAM roles trusting external AWS accounts should require an ExternalId condition. The "confused deputy" attack: a third-party SaaS holds an IAM role that @@ -842,15 +842,17 @@ def check_iam_role_trust_external_account( for page in paginator.paginate(): roles.extend(page.get("Roles", [])) except Exception as e: - return [Finding.not_assessed( - check_id="iam-role-trust-external", - title="Unable to check IAM role trust policies", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="AWS::IAM::Role", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="iam-role-trust-external", + title="Unable to check IAM role trust policies", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="AWS::IAM::Role", + account_id=account_id, + region=region, + ) + ] if not roles: return [] @@ -891,12 +893,8 @@ def check_iam_role_trust_external_account( continue cond = stmt.get("Condition", {}) or {} has_external_id = ( - "StringEquals" in cond - and "sts:ExternalId" in cond.get("StringEquals", {}) - ) or ( - "StringLike" in cond - and "sts:ExternalId" in cond.get("StringLike", {}) - ) + "StringEquals" in cond and "sts:ExternalId" in cond.get("StringEquals", {}) + ) or ("StringLike" in cond and "sts:ExternalId" in cond.get("StringLike", {})) if not has_external_id: offenders.append( { @@ -959,9 +957,7 @@ def check_iam_role_trust_external_account( UNUSED_ROLE_DAYS_THRESHOLD = 90 -def check_iam_unused_roles( - iam: Any, account_id: str, region: str -) -> list[Finding]: +def check_iam_unused_roles(iam: Any, account_id: str, region: str) -> list[Finding]: """IAM roles with no LastUsedDate (or LastUsedDate >90 days) are stale. Mirrors check_inactive_users but for roles. Stale roles accumulate @@ -974,20 +970,22 @@ def check_iam_unused_roles( for page in paginator.paginate(): roles.extend(page.get("Roles", [])) except Exception as e: - return [Finding.not_assessed( - check_id="iam-unused-roles", - title="Unable to check unused IAM roles", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="AWS::IAM::Role", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="iam-unused-roles", + title="Unable to check unused IAM roles", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="AWS::IAM::Role", + account_id=account_id, + region=region, + ) + ] if not roles: return [] - threshold = datetime.now(timezone.utc) - timedelta(days=UNUSED_ROLE_DAYS_THRESHOLD) + threshold = datetime.now(UTC) - timedelta(days=UNUSED_ROLE_DAYS_THRESHOLD) stale: list[dict] = [] for role in roles: path = role.get("Path", "/") @@ -1000,7 +998,7 @@ def check_iam_unused_roles( last_used_info = detail.get("RoleLastUsed", {}) or {} last_used = last_used_info.get("LastUsedDate") created = role.get("CreateDate") - if created and (datetime.now(timezone.utc) - created).days < UNUSED_ROLE_DAYS_THRESHOLD: + if created and (datetime.now(UTC) - created).days < UNUSED_ROLE_DAYS_THRESHOLD: continue if last_used is None: stale.append( @@ -1012,7 +1010,7 @@ def check_iam_unused_roles( } ) elif last_used < threshold: - days_ago = (datetime.now(timezone.utc) - last_used).days + days_ago = (datetime.now(UTC) - last_used).days stale.append( { "role_name": role.get("RoleName"), diff --git a/src/shasta/aws/kms.py b/src/shasta/aws/kms.py index 4b57cd9..d101b93 100644 --- a/src/shasta/aws/kms.py +++ b/src/shasta/aws/kms.py @@ -12,7 +12,6 @@ from __future__ import annotations import json -from typing import Any from botocore.exceptions import ClientError @@ -24,7 +23,6 @@ Severity, ) - # This module iterates regions per Engineering Principle #3. IS_GLOBAL = False @@ -82,28 +80,27 @@ def _list_customer_keys(client: AWSClient) -> list[dict]: return out -def check_kms_key_rotation( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_kms_key_rotation(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS 3.8] Customer-managed KMS keys should have annual rotation enabled. Without rotation, the same key material protects data forever. NIST SP 800-57 recommends annual rotation for symmetric keys protecting bulk data. AWS KMS auto-rotation is one boolean — there's no excuse to leave it off. """ - findings: list[Finding] = [] try: kms = client.client("kms") except ClientError as e: - return [Finding.not_assessed( - check_id="kms-key-rotation", - title="Unable to check KMS key rotation", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::KMS::Key", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="kms-key-rotation", + title="Unable to check KMS key rotation", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::KMS::Key", + account_id=account_id, + region=region, + ) + ] keys = _list_customer_keys(client) if not keys: @@ -198,7 +195,6 @@ def check_kms_key_policy_wildcards( equivalent of a public S3 bucket: anyone in any AWS account can encrypt, decrypt, schedule deletion, or take ownership of the key. """ - findings: list[Finding] = [] keys = _list_customer_keys(client) if not keys: return [] @@ -265,7 +261,7 @@ def check_kms_key_policy_wildcards( check_id="kms-key-policy-wildcards", title=f"{len(offenders)} CMK(s) with wildcard Principal+Action key policies", description=( - "These keys grant `kms:*` (or `*`) to `Principal: \"*\"` with no Condition. " + 'These keys grant `kms:*` (or `*`) to `Principal: "*"` with no Condition. ' "Anyone in any AWS account can encrypt, decrypt, take ownership, or schedule " "deletion. This is the KMS equivalent of a public S3 bucket — and unlike S3 " "Public Access Block, there's no account-wide guardrail that prevents it." @@ -290,9 +286,7 @@ def check_kms_key_policy_wildcards( ] -def check_kms_scheduled_deletion( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_kms_scheduled_deletion(client: AWSClient, account_id: str, region: str) -> list[Finding]: """KMS keys in PendingDeletion state should be flagged immediately. A key in PendingDeletion will be permanently destroyed in 7-30 days, @@ -374,7 +368,6 @@ def check_kms_no_unrestricted_principal( grants without a Condition narrowing the source. Cross-account access is legitimate but should always carry a SourceArn / SourceAccount condition. """ - findings: list[Finding] = [] keys = _list_customer_keys(client) if not keys: return [] @@ -398,10 +391,7 @@ def check_kms_no_unrestricted_principal( cross_acct_principals = [ p for p in aws_principals - if isinstance(p, str) - and p != own_account_root - and p != "*" - and ":root" in p + if isinstance(p, str) and p != own_account_root and p != "*" and ":root" in p ] if cross_acct_principals and not stmt.get("Condition"): cross_account.append( diff --git a/src/shasta/aws/logging_checks.py b/src/shasta/aws/logging_checks.py index 04c43e2..d362c55 100644 --- a/src/shasta/aws/logging_checks.py +++ b/src/shasta/aws/logging_checks.py @@ -165,15 +165,17 @@ def check_cloudtrail_kms_encryption( ct = client.client("cloudtrail") trails = ct.describe_trails().get("trailList", []) except ClientError as e: - return [Finding.not_assessed( - check_id="cloudtrail-kms-encryption", - title="Unable to check CloudTrail KMS encryption", - description=f"API call failed: {e}", - domain=CheckDomain.MONITORING, - resource_type="AWS::CloudTrail::Trail", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="cloudtrail-kms-encryption", + title="Unable to check CloudTrail KMS encryption", + description=f"API call failed: {e}", + domain=CheckDomain.MONITORING, + resource_type="AWS::CloudTrail::Trail", + account_id=account_id, + region=region, + ) + ] for trail in trails: name = trail.get("Name", "unknown") @@ -237,15 +239,17 @@ def check_cloudtrail_log_validation( ct = client.client("cloudtrail") trails = ct.describe_trails().get("trailList", []) except ClientError as e: - return [Finding.not_assessed( - check_id="cloudtrail-log-validation", - title="Unable to check CloudTrail log validation", - description=f"API call failed: {e}", - domain=CheckDomain.MONITORING, - resource_type="AWS::CloudTrail::Trail", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="cloudtrail-log-validation", + title="Unable to check CloudTrail log validation", + description=f"API call failed: {e}", + domain=CheckDomain.MONITORING, + resource_type="AWS::CloudTrail::Trail", + account_id=account_id, + region=region, + ) + ] for trail in trails: name = trail.get("Name", "unknown") @@ -308,15 +312,17 @@ def check_cloudtrail_s3_object_lock( s3 = client.client("s3") trails = ct.describe_trails().get("trailList", []) except ClientError as e: - return [Finding.not_assessed( - check_id="cloudtrail-s3-object-lock", - title="Unable to check CloudTrail S3 Object Lock", - description=f"API call failed: {e}", - domain=CheckDomain.MONITORING, - resource_type="AWS::S3::Bucket", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="cloudtrail-s3-object-lock", + title="Unable to check CloudTrail S3 Object Lock", + description=f"API call failed: {e}", + domain=CheckDomain.MONITORING, + resource_type="AWS::S3::Bucket", + account_id=account_id, + region=region, + ) + ] seen_buckets: set[str] = set() for trail in trails: @@ -385,7 +391,6 @@ def check_cloudtrail_s3_object_lock( def check_security_hub(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS 4.16] AWS Security Hub should be enabled in every region.""" - findings: list[Finding] = [] try: regions = client.get_enabled_regions() except ClientError: @@ -470,7 +475,6 @@ def check_security_hub(client: AWSClient, account_id: str, region: str) -> list[ def check_iam_access_analyzer(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS 1.20] IAM Access Analyzer should be enabled in every region.""" - findings: list[Finding] = [] try: regions = client.get_enabled_regions() except ClientError: @@ -1131,9 +1135,7 @@ def check_cloudwatch_alarms_cis_4_x( filters_resp = logs.describe_metric_filters(logGroupName=log_group_name) metric_filters = filters_resp.get("metricFilters", []) alarms_resp = cw.describe_alarms() - alarm_metric_names = { - a.get("MetricName") for a in alarms_resp.get("MetricAlarms", []) - } + alarm_metric_names = {a.get("MetricName") for a in alarms_resp.get("MetricAlarms", [])} except ClientError as e: code = e.response.get("Error", {}).get("Code", "") return [ diff --git a/src/shasta/aws/networking.py b/src/shasta/aws/networking.py index 8c0c3f5..5aafeb2 100644 --- a/src/shasta/aws/networking.py +++ b/src/shasta/aws/networking.py @@ -74,15 +74,17 @@ def check_elb_listeners(client: AWSClient, account_id: str, region: str) -> list elbv2 = client.client("elbv2") lbs = elbv2.describe_load_balancers().get("LoadBalancers", []) except ClientError as e: - return [Finding.not_assessed( - check_id="elb-listener-tls", - title="Unable to check ELB listener TLS", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="AWS::ElasticLoadBalancingV2::LoadBalancer", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="elb-listener-tls", + title="Unable to check ELB listener TLS", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="AWS::ElasticLoadBalancingV2::LoadBalancer", + account_id=account_id, + region=region, + ) + ] for lb in lbs: arn = lb.get("LoadBalancerArn", "") @@ -171,15 +173,17 @@ def check_elb_access_logs(client: AWSClient, account_id: str, region: str) -> li elbv2 = client.client("elbv2") lbs = elbv2.describe_load_balancers().get("LoadBalancers", []) except ClientError as e: - return [Finding.not_assessed( - check_id="elb-access-logs", - title="Unable to check ELB access logs", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="AWS::ElasticLoadBalancingV2::LoadBalancer", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="elb-access-logs", + title="Unable to check ELB access logs", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="AWS::ElasticLoadBalancingV2::LoadBalancer", + account_id=account_id, + region=region, + ) + ] for lb in lbs: arn = lb.get("LoadBalancerArn", "") @@ -251,15 +255,17 @@ def check_elb_drop_invalid_headers( elbv2 = client.client("elbv2") lbs = elbv2.describe_load_balancers().get("LoadBalancers", []) except ClientError as e: - return [Finding.not_assessed( - check_id="elb-drop-invalid-headers", - title="Unable to check ELB invalid header handling", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="AWS::ElasticLoadBalancingV2::LoadBalancer", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="elb-drop-invalid-headers", + title="Unable to check ELB invalid header handling", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="AWS::ElasticLoadBalancingV2::LoadBalancer", + account_id=account_id, + region=region, + ) + ] for lb in lbs: if lb.get("Type") != "application": @@ -506,7 +512,6 @@ def check_default_security_groups(ec2: Any, account_id: str, region: str) -> lis vpc_id = sg.get("VpcId", "N/A") has_ingress = len(sg.get("IpPermissions", [])) > 0 - has_egress_beyond_default = False for rule in sg.get("IpPermissionsEgress", []): # Default SG has one egress rule allowing all outbound — that's the AWS default # We flag if ingress rules exist (should be empty) diff --git a/src/shasta/aws/organizations.py b/src/shasta/aws/organizations.py index ec8a935..c1d9fe3 100644 --- a/src/shasta/aws/organizations.py +++ b/src/shasta/aws/organizations.py @@ -194,15 +194,17 @@ def check_tag_policy(client: AWSClient, account_id: str, region: str) -> list[Fi try: policies = org.list_policies(Filter="TAG_POLICY").get("Policies", []) except ClientError as e: - return [Finding.not_assessed( - check_id="aws-tag-policy", - title="Unable to check tag policies", - description=f"API call failed: {e}", - domain=CheckDomain.MONITORING, - resource_type="AWS::Organizations::Policy", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="aws-tag-policy", + title="Unable to check tag policies", + description=f"API call failed: {e}", + domain=CheckDomain.MONITORING, + resource_type="AWS::Organizations::Policy", + account_id=account_id, + region=region, + ) + ] if policies: return [ @@ -254,15 +256,17 @@ def check_backup_policy(client: AWSClient, account_id: str, region: str) -> list try: policies = org.list_policies(Filter="BACKUP_POLICY").get("Policies", []) except ClientError as e: - return [Finding.not_assessed( - check_id="aws-backup-policy", - title="Unable to check org-level backup policies", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::Organizations::Policy", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="aws-backup-policy", + title="Unable to check org-level backup policies", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::Organizations::Policy", + account_id=account_id, + region=region, + ) + ] if policies: return [ @@ -315,15 +319,17 @@ def check_delegated_admin(client: AWSClient, account_id: str, region: str) -> li try: delegated = org.list_delegated_administrators().get("DelegatedAdministrators", []) except ClientError as e: - return [Finding.not_assessed( - check_id="aws-delegated-admin", - title="Unable to check delegated administrators", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="AWS::Organizations::DelegatedAdministrator", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="aws-delegated-admin", + title="Unable to check delegated administrators", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="AWS::Organizations::DelegatedAdministrator", + account_id=account_id, + region=region, + ) + ] if delegated: return [ diff --git a/src/shasta/aws/pentest.py b/src/shasta/aws/pentest.py index a6c1d2d..23364ff 100644 --- a/src/shasta/aws/pentest.py +++ b/src/shasta/aws/pentest.py @@ -19,11 +19,9 @@ from __future__ import annotations -import json from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any from botocore.exceptions import ClientError @@ -61,7 +59,7 @@ def run_security_assessment(client: AWSClient) -> PenTestReport: report = PenTestReport( account_id=account_id, - assessed_at=datetime.now(timezone.utc).isoformat(), + assessed_at=datetime.now(UTC).isoformat(), ) try: @@ -188,7 +186,7 @@ def _find_exposed_resources( for lb in lbs.get("LoadBalancers", []): if lb.get("Scheme") == "internet-facing": listeners = elbv2.describe_listeners(LoadBalancerArn=lb["LoadBalancerArn"]) - ports = [l.get("Port", 0) for l in listeners.get("Listeners", [])] + ports = [listener.get("Port", 0) for listener in listeners.get("Listeners", [])] exposed.append( ExposedResource( resource_id=lb.get("LoadBalancerName", ""), diff --git a/src/shasta/aws/serverless.py b/src/shasta/aws/serverless.py index 66baf06..4a3d2e8 100644 --- a/src/shasta/aws/serverless.py +++ b/src/shasta/aws/serverless.py @@ -7,8 +7,6 @@ from __future__ import annotations -from typing import Any - from botocore.exceptions import ClientError from shasta.aws.client import AWSClient @@ -19,7 +17,6 @@ Severity, ) - # Lambda runtimes that AWS has marked deprecated. Updated for 2026. DEPRECATED_LAMBDA_RUNTIMES = { "nodejs", @@ -98,19 +95,20 @@ def check_lambda_function_url_auth( costs). This is one of the most common new misconfigurations in AWS as of 2026. """ - findings: list[Finding] = [] try: lam = client.client("lambda") except ClientError as e: - return [Finding.not_assessed( - check_id="lambda-function-url-auth", - title="Unable to check Lambda Function URL auth", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="AWS::Lambda::Url", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="lambda-function-url-auth", + title="Unable to check Lambda Function URL auth", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="AWS::Lambda::Url", + account_id=account_id, + region=region, + ) + ] fns = _lambda_functions(client) if not fns: @@ -189,9 +187,7 @@ def check_lambda_function_url_auth( ] -def check_lambda_layer_origin( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_lambda_layer_origin(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Lambda layers should come from your own account, not foreign accounts. Lambda layers can be sourced from any AWS account that grants you @@ -199,7 +195,6 @@ def check_lambda_layer_origin( chain risk: the layer publisher can ship arbitrary code that runs in your function's execution context. """ - findings: list[Finding] = [] fns = _lambda_functions(client) if not fns: return [] @@ -286,20 +281,21 @@ def check_apigw_client_certificate( request originated from your API Gateway, not from someone who guessed the backend URL. """ - findings: list[Finding] = [] try: apigw = client.client("apigateway") apis = apigw.get_rest_apis().get("items", []) except ClientError as e: - return [Finding.not_assessed( - check_id="apigw-client-cert", - title="Unable to check API Gateway client certificates", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="AWS::ApiGateway::Stage", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="apigw-client-cert", + title="Unable to check API Gateway client certificates", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="AWS::ApiGateway::Stage", + account_id=account_id, + region=region, + ) + ] if not apis: return [] @@ -374,20 +370,21 @@ def check_apigw_authorizer_required( Methods with AuthorizationType=NONE are publicly callable. This is the AWS equivalent of Azure App Service Easy Auth being disabled. """ - findings: list[Finding] = [] try: apigw = client.client("apigateway") apis = apigw.get_rest_apis().get("items", []) except ClientError as e: - return [Finding.not_assessed( - check_id="apigw-authorizer", - title="Unable to check API Gateway authorizers", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="AWS::ApiGateway::Method", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="apigw-authorizer", + title="Unable to check API Gateway authorizers", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="AWS::ApiGateway::Method", + account_id=account_id, + region=region, + ) + ] if not apis: return [] @@ -463,24 +460,23 @@ def check_apigw_authorizer_required( ] -def check_apigw_throttling( - client: AWSClient, account_id: str, region: str -) -> list[Finding]: +def check_apigw_throttling(client: AWSClient, account_id: str, region: str) -> list[Finding]: """API Gateway stages should have throttling configured to prevent abuse / cost overruns.""" - findings: list[Finding] = [] try: apigw = client.client("apigateway") apis = apigw.get_rest_apis().get("items", []) except ClientError as e: - return [Finding.not_assessed( - check_id="apigw-throttling", - title="Unable to check API Gateway throttling", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="AWS::ApiGateway::Stage", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="apigw-throttling", + title="Unable to check API Gateway throttling", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="AWS::ApiGateway::Stage", + account_id=account_id, + region=region, + ) + ] if not apis: return [] @@ -559,20 +555,21 @@ def check_apigw_request_validation( Lambda — wasting compute and exposing the backend to fuzz inputs. Validation rejects bad requests at the edge. """ - findings: list[Finding] = [] try: apigw = client.client("apigateway") apis = apigw.get_rest_apis().get("items", []) except ClientError as e: - return [Finding.not_assessed( - check_id="apigw-request-validation", - title="Unable to check API Gateway request validation", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="AWS::ApiGateway::RestApi", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="apigw-request-validation", + title="Unable to check API Gateway request validation", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="AWS::ApiGateway::RestApi", + account_id=account_id, + region=region, + ) + ] if not apis: return [] @@ -653,7 +650,6 @@ def _lambda_functions(client: AWSClient) -> list[dict]: def check_lambda_runtime_eol(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS] Lambda functions on deprecated runtimes are unmaintained.""" - findings: list[Finding] = [] fns = _lambda_functions(client) if not fns: return [] @@ -714,7 +710,6 @@ def check_lambda_runtime_eol(client: AWSClient, account_id: str, region: str) -> def check_lambda_env_encryption(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Lambda env vars should be encrypted with a customer-managed KMS key.""" - findings: list[Finding] = [] fns = _lambda_functions(client) no_cmk: list[str] = [] has_cmk = 0 @@ -778,7 +773,6 @@ def check_lambda_env_encryption(client: AWSClient, account_id: str, region: str) def check_lambda_dead_letter(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Async Lambda invocations should have a dead-letter queue or destination.""" - findings: list[Finding] = [] fns = _lambda_functions(client) no_dlq: list[str] = [] for f in fns: @@ -832,7 +826,6 @@ def check_lambda_dead_letter(client: AWSClient, account_id: str, region: str) -> def check_lambda_code_signing(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Lambda functions should require code signing for supply-chain integrity.""" - findings: list[Finding] = [] fns = _lambda_functions(client) if not fns: return [] @@ -896,15 +889,17 @@ def check_apigw_logging(client: AWSClient, account_id: str, region: str) -> list apigw = client.client("apigateway") apis = apigw.get_rest_apis().get("items", []) except ClientError as e: - return [Finding.not_assessed( - check_id="apigw-logging", - title="Unable to check API Gateway logging", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::ApiGateway::Stage", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="apigw-logging", + title="Unable to check API Gateway logging", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::ApiGateway::Stage", + account_id=account_id, + region=region, + ) + ] for api in apis: api_id = api.get("id", "") @@ -966,20 +961,21 @@ def check_apigw_logging(client: AWSClient, account_id: str, region: str) -> list def check_apigw_waf(client: AWSClient, account_id: str, region: str) -> list[Finding]: """[CIS AWS] Public API Gateway stages should have AWS WAF associated.""" - findings: list[Finding] = [] try: apigw = client.client("apigateway") apis = apigw.get_rest_apis().get("items", []) except ClientError as e: - return [Finding.not_assessed( - check_id="apigw-waf", - title="Unable to check API Gateway WAF", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="AWS::ApiGateway::Stage", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="apigw-waf", + title="Unable to check API Gateway WAF", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="AWS::ApiGateway::Stage", + account_id=account_id, + region=region, + ) + ] if not apis: return [] @@ -1052,20 +1048,21 @@ def check_apigw_waf(client: AWSClient, account_id: str, region: str) -> list[Fin def check_stepfunctions_logging(client: AWSClient, account_id: str, region: str) -> list[Finding]: """Step Functions state machines should have execution history logging enabled.""" - findings: list[Finding] = [] try: sfn = client.client("stepfunctions") machines = sfn.list_state_machines().get("stateMachines", []) except ClientError as e: - return [Finding.not_assessed( - check_id="sfn-logging", - title="Unable to check Step Functions logging", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::StepFunctions::StateMachine", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="sfn-logging", + title="Unable to check Step Functions logging", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::StepFunctions::StateMachine", + account_id=account_id, + region=region, + ) + ] if not machines: return [] diff --git a/src/shasta/aws/storage.py b/src/shasta/aws/storage.py index 5c26d9f..dba1349 100644 --- a/src/shasta/aws/storage.py +++ b/src/shasta/aws/storage.py @@ -63,15 +63,17 @@ def check_s3_object_ownership_enforced( if code == "OwnershipControlsNotFoundError": ownership = "ObjectWriter" # Legacy default else: - return [Finding.not_assessed( - check_id="s3-object-ownership", - title=f"Unable to check S3 bucket '{bucket_name}' object ownership", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="AWS::S3::Bucket", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="s3-object-ownership", + title=f"Unable to check S3 bucket '{bucket_name}' object ownership", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="AWS::S3::Bucket", + account_id=account_id, + region=region, + ) + ] if ownership == "BucketOwnerEnforced": return [ @@ -131,15 +133,17 @@ def check_s3_access_logging( resp = s3.get_bucket_logging(Bucket=bucket_name) logging_enabled = resp.get("LoggingEnabled") except ClientError as e: - return [Finding.not_assessed( - check_id="s3-access-logging", - title=f"Unable to check S3 bucket '{bucket_name}' access logging", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="AWS::S3::Bucket", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="s3-access-logging", + title=f"Unable to check S3 bucket '{bucket_name}' access logging", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="AWS::S3::Bucket", + account_id=account_id, + region=region, + ) + ] if logging_enabled and logging_enabled.get("TargetBucket"): target = logging_enabled.get("TargetBucket") @@ -178,8 +182,8 @@ def check_s3_access_logging( remediation=( "Create a dedicated log destination bucket with Object Lock + lifecycle rules, " f"then aws s3api put-bucket-logging --bucket {bucket_name} --bucket-logging-status " - "'{\"LoggingEnabled\":{\"TargetBucket\":\"\",\"TargetPrefix\":" - f"\"{bucket_name}/\"}}'" + '\'{"LoggingEnabled":{"TargetBucket":"","TargetPrefix":' + f'"{bucket_name}/"}}\'' ), soc2_controls=["CC7.1"], cis_aws_controls=["2.x"], @@ -222,15 +226,17 @@ def check_s3_kms_cmk_encryption( algo = sse_default.get("SSEAlgorithm", "") kms_key = sse_default.get("KMSMasterKeyID", "") except ClientError as e: - return [Finding.not_assessed( - check_id="s3-kms-cmk", - title=f"Unable to check S3 bucket '{bucket_name}' KMS CMK encryption", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="AWS::S3::Bucket", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="s3-kms-cmk", + title=f"Unable to check S3 bucket '{bucket_name}' KMS CMK encryption", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="AWS::S3::Bucket", + account_id=account_id, + region=region, + ) + ] if algo == "aws:kms" and kms_key and "alias/aws/s3" not in kms_key: return [ @@ -270,8 +276,8 @@ def check_s3_kms_cmk_encryption( "Create a customer-managed KMS key with rotation enabled, then " f"aws s3api put-bucket-encryption --bucket {bucket_name} " "--server-side-encryption-configuration " - "'{\"Rules\":[{\"ApplyServerSideEncryptionByDefault\":" - "{\"SSEAlgorithm\":\"aws:kms\",\"KMSMasterKeyID\":\"\"}}]}'" + '\'{"Rules":[{"ApplyServerSideEncryptionByDefault":' + '{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":""}}]}\'' ), soc2_controls=["CC6.7"], cis_aws_controls=["2.x"], @@ -329,15 +335,17 @@ def check_s3_encryption(s3: Any, bucket_name: str, account_id: str, region: str) details={"bucket": bucket_name, "encryption": None}, ) ] - return [Finding.not_assessed( - check_id="s3-encryption-at-rest", - title=f"Unable to check S3 bucket '{bucket_name}' encryption", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="AWS::S3::Bucket", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="s3-encryption-at-rest", + title=f"Unable to check S3 bucket '{bucket_name}' encryption", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="AWS::S3::Bucket", + account_id=account_id, + region=region, + ) + ] return [] @@ -384,15 +392,17 @@ def check_s3_versioning(s3: Any, bucket_name: str, account_id: str, region: str) ) ] except ClientError as e: - return [Finding.not_assessed( - check_id="s3-versioning", - title=f"Unable to check S3 bucket '{bucket_name}' versioning", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="AWS::S3::Bucket", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="s3-versioning", + title=f"Unable to check S3 bucket '{bucket_name}' versioning", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="AWS::S3::Bucket", + account_id=account_id, + region=region, + ) + ] def check_s3_public_access_block( @@ -475,15 +485,17 @@ def check_s3_public_access_block( details={"bucket": bucket_name, "public_access_block": None}, ) ] - return [Finding.not_assessed( - check_id="s3-public-access-block", - title=f"Unable to check S3 bucket '{bucket_name}' public access block", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="AWS::S3::Bucket", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="s3-public-access-block", + title=f"Unable to check S3 bucket '{bucket_name}' public access block", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="AWS::S3::Bucket", + account_id=account_id, + region=region, + ) + ] def check_s3_ssl_only(s3: Any, bucket_name: str, account_id: str, region: str) -> list[Finding]: @@ -560,12 +572,14 @@ def check_s3_ssl_only(s3: Any, bucket_name: str, account_id: str, region: str) - details={"bucket": bucket_name, "ssl_enforced": False, "policy_exists": False}, ) ] - return [Finding.not_assessed( - check_id="s3-ssl-only", - title=f"Unable to check S3 bucket '{bucket_name}' SSL enforcement", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="AWS::S3::Bucket", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="s3-ssl-only", + title=f"Unable to check S3 bucket '{bucket_name}' SSL enforcement", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="AWS::S3::Bucket", + account_id=account_id, + region=region, + ) + ] diff --git a/src/shasta/aws/vpc_endpoints.py b/src/shasta/aws/vpc_endpoints.py index 8f77ed8..252b03a 100644 --- a/src/shasta/aws/vpc_endpoints.py +++ b/src/shasta/aws/vpc_endpoints.py @@ -20,7 +20,6 @@ Severity, ) - # Services that strongly benefit from a VPC endpoint when used from inside a VPC. # Maps endpoint service-name suffix to (display name, severity). EXPECTED_VPC_ENDPOINTS = { @@ -67,15 +66,17 @@ def _check_region(client: AWSClient, account_id: str, region: str) -> list[Findi vpcs = ec2.describe_vpcs().get("Vpcs", []) endpoints = ec2.describe_vpc_endpoints().get("VpcEndpoints", []) except ClientError as e: - return [Finding.not_assessed( - check_id="aws-vpc-endpoints", - title="Unable to check VPC endpoints", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="AWS::EC2::VPC", - account_id=account_id, - region=region, - )] + return [ + Finding.not_assessed( + check_id="aws-vpc-endpoints", + title="Unable to check VPC endpoints", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="AWS::EC2::VPC", + account_id=account_id, + region=region, + ) + ] if not vpcs: return [] diff --git a/src/shasta/aws/vulnerabilities.py b/src/shasta/aws/vulnerabilities.py index 4f358b1..a10e1d5 100644 --- a/src/shasta/aws/vulnerabilities.py +++ b/src/shasta/aws/vulnerabilities.py @@ -26,10 +26,10 @@ def run_all_vulnerability_checks(client: AWSClient) -> list[Finding]: regions = [region] # Multi-region rollup - enabled_regions, disabled_regions = _inspector_status_per_region( - client, account_id, regions + enabled_regions, disabled_regions = _inspector_status_per_region(client, account_id, regions) + findings.extend( + _inspector_enable_findings(account_id, region, enabled_regions, disabled_regions) ) - findings.extend(_inspector_enable_findings(account_id, region, enabled_regions, disabled_regions)) # Per-region findings — only query regions where Inspector is enabled for r in sorted(enabled_regions.keys()): diff --git a/src/shasta/azure/ai_checks.py b/src/shasta/azure/ai_checks.py index d1a4fd4..6c7add3 100644 --- a/src/shasta/azure/ai_checks.py +++ b/src/shasta/azure/ai_checks.py @@ -682,7 +682,6 @@ def check_azure_openai_abuse_monitoring( # It can only be disabled via an approved exception. # We check the API properties for dynamic_throttling_enabled # as a proxy for whether the account has standard safeguards. - props = account.properties # Abuse monitoring opt-out would show in restricted_access_uri or similar; # since it's on by default and requires MS approval to disable, we PASS # but note if the account has any unusual config. @@ -1461,7 +1460,7 @@ def check_azure_ai_search_auth(client: AzureClient, sub_id: str, region: str) -> findings: list[Finding] = [] for svc in services: - auth_options = getattr(svc, "auth_options", None) + getattr(svc, "auth_options", None) disable_local_auth = getattr(svc, "disable_local_auth", False) if disable_local_auth: diff --git a/src/shasta/azure/appservice.py b/src/shasta/azure/appservice.py index 2662f15..3143d79 100644 --- a/src/shasta/azure/appservice.py +++ b/src/shasta/azure/appservice.py @@ -104,16 +104,18 @@ def check_appservice_https_only( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-appservice-https-only", - title="Unable to check App Service HTTPS-only", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="Azure::Web::Site", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-appservice-https-only", + title="Unable to check App Service HTTPS-only", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="Azure::Web::Site", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -155,16 +157,18 @@ def check_appservice_min_tls( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-appservice-min-tls", - title="Unable to check App Service min TLS", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="Azure::Web::Site", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-appservice-min-tls", + title="Unable to check App Service min TLS", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="Azure::Web::Site", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -199,16 +203,18 @@ def check_appservice_ftps_disabled( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-appservice-ftps", - title="Unable to check App Service FTPS state", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="Azure::Web::Site", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-appservice-ftps", + title="Unable to check App Service FTPS state", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="Azure::Web::Site", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -245,16 +251,18 @@ def check_appservice_remote_debug_disabled( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-appservice-remote-debug", - title="Unable to check App Service remote debugging", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="Azure::Web::Site", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-appservice-remote-debug", + title="Unable to check App Service remote debugging", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="Azure::Web::Site", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -286,16 +294,18 @@ def check_appservice_client_cert( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-appservice-client-cert", - title="Unable to check App Service client certificates", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="Azure::Web::Site", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-appservice-client-cert", + title="Unable to check App Service client certificates", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="Azure::Web::Site", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -331,16 +341,18 @@ def check_appservice_managed_identity( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-appservice-managed-identity", - title="Unable to check App Service managed identity", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="Azure::Web::Site", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-appservice-managed-identity", + title="Unable to check App Service managed identity", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="Azure::Web::Site", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -375,16 +387,18 @@ def check_appservice_public_network_access( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-appservice-public-access", - title="Unable to check App Service public network access", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="Azure::Web::Site", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-appservice-public-access", + title="Unable to check App Service public network access", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="Azure::Web::Site", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -422,14 +436,16 @@ def check_appservice_auth_enabled( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-appservice-auth", - title="Unable to check App Service authentication", - description=f"API call failed: {e}", - domain=CheckDomain.COMPUTE, - resource_type="Azure::Web::Site", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-appservice-auth", + title="Unable to check App Service authentication", + description=f"API call failed: {e}", + domain=CheckDomain.COMPUTE, + resource_type="Azure::Web::Site", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings diff --git a/src/shasta/azure/backup.py b/src/shasta/azure/backup.py index 835b1b8..1ae4ae2 100644 --- a/src/shasta/azure/backup.py +++ b/src/shasta/azure/backup.py @@ -91,16 +91,18 @@ def check_rsv_exists(client: AzureClient, subscription_id: str, region: str) -> try: vaults = list(client.mgmt_client(_rs_class()).vaults.list_by_subscription_id()) except Exception as e: - return [Finding.not_assessed( - check_id="azure-rsv-exists", - title="Unable to check Recovery Services Vaults", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::RecoveryServices::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-rsv-exists", + title="Unable to check Recovery Services Vaults", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::RecoveryServices::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] if vaults: return [ Finding( @@ -188,16 +190,18 @@ def check_rsv_soft_delete(client: AzureClient, subscription_id: str, region: str ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-rsv-soft-delete", - title="Unable to check RSV soft delete", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::RecoveryServices::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-rsv-soft-delete", + title="Unable to check RSV soft delete", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::RecoveryServices::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -242,16 +246,18 @@ def check_rsv_immutability(client: AzureClient, subscription_id: str, region: st ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-rsv-immutability", - title="Unable to check RSV immutability", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::RecoveryServices::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-rsv-immutability", + title="Unable to check RSV immutability", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::RecoveryServices::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -291,16 +297,18 @@ def check_rsv_cross_region_restore( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-rsv-crr", - title="Unable to check RSV cross-region restore", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::RecoveryServices::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-rsv-crr", + title="Unable to check RSV cross-region restore", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::RecoveryServices::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -338,16 +346,18 @@ def check_rsv_redundancy(client: AzureClient, subscription_id: str, region: str) ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-rsv-redundancy", - title="Unable to check RSV storage redundancy", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::RecoveryServices::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-rsv-redundancy", + title="Unable to check RSV storage redundancy", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::RecoveryServices::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -381,16 +391,18 @@ def check_rsv_cmk(client: AzureClient, subscription_id: str, region: str) -> lis ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-rsv-cmk", - title="Unable to check RSV CMK encryption", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::RecoveryServices::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-rsv-cmk", + title="Unable to check RSV CMK encryption", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::RecoveryServices::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -424,16 +436,18 @@ def check_rsv_mua(client: AzureClient, subscription_id: str, region: str) -> lis ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-rsv-mua", - title="Unable to check RSV multi-user authorization", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::RecoveryServices::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-rsv-mua", + title="Unable to check RSV multi-user authorization", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::RecoveryServices::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -468,14 +482,16 @@ def check_rsv_public_access( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-rsv-public-access", - title="Unable to check RSV public access", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::RecoveryServices::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-rsv-public-access", + title="Unable to check RSV public access", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::RecoveryServices::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings diff --git a/src/shasta/azure/databases.py b/src/shasta/azure/databases.py index d03424a..11d1444 100644 --- a/src/shasta/azure/databases.py +++ b/src/shasta/azure/databases.py @@ -125,16 +125,18 @@ def check_cosmos_disable_local_auth( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-cosmos-disable-local-auth", - title="Unable to check Cosmos DB local auth", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="Azure::Cosmos::DatabaseAccount", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-cosmos-disable-local-auth", + title="Unable to check Cosmos DB local auth", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="Azure::Cosmos::DatabaseAccount", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -177,16 +179,18 @@ def check_cosmos_public_network_access( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-cosmos-public-access", - title="Unable to check Cosmos DB public network access", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="Azure::Cosmos::DatabaseAccount", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-cosmos-public-access", + title="Unable to check Cosmos DB public network access", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="Azure::Cosmos::DatabaseAccount", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -243,16 +247,18 @@ def check_cosmos_firewall_rules( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-cosmos-firewall", - title="Unable to check Cosmos DB firewall rules", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="Azure::Cosmos::DatabaseAccount", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-cosmos-firewall", + title="Unable to check Cosmos DB firewall rules", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="Azure::Cosmos::DatabaseAccount", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -284,16 +290,18 @@ def check_cosmos_metadata_write_disabled( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-cosmos-metadata-write", - title="Unable to check Cosmos DB metadata write access", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="Azure::Cosmos::DatabaseAccount", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-cosmos-metadata-write", + title="Unable to check Cosmos DB metadata write access", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="Azure::Cosmos::DatabaseAccount", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -335,16 +343,18 @@ def check_cosmos_cmk(client: AzureClient, subscription_id: str, region: str) -> ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-cosmos-cmk", - title="Unable to check Cosmos DB CMK encryption", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="Azure::Cosmos::DatabaseAccount", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-cosmos-cmk", + title="Unable to check Cosmos DB CMK encryption", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="Azure::Cosmos::DatabaseAccount", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -414,16 +424,18 @@ def check_postgresql_secure_transport( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-postgres-secure-transport", - title="Unable to check PostgreSQL secure transport", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="Azure::DBforPostgreSQL::FlexibleServer", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-postgres-secure-transport", + title="Unable to check PostgreSQL secure transport", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="Azure::DBforPostgreSQL::FlexibleServer", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -484,16 +496,18 @@ def check_postgresql_log_settings( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-postgres-log-settings", - title="Unable to check PostgreSQL log settings", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::DBforPostgreSQL::FlexibleServer", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-postgres-log-settings", + title="Unable to check PostgreSQL log settings", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::DBforPostgreSQL::FlexibleServer", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -535,16 +549,18 @@ def check_postgresql_public_access( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-postgres-public-access", - title="Unable to check PostgreSQL public access", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::DBforPostgreSQL::FlexibleServer", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-postgres-public-access", + title="Unable to check PostgreSQL public access", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::DBforPostgreSQL::FlexibleServer", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -607,16 +623,18 @@ def check_mysql_secure_transport( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-mysql-secure-transport", - title="Unable to check MySQL secure transport", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="Azure::DBforMySQL::FlexibleServer", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-mysql-secure-transport", + title="Unable to check MySQL secure transport", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="Azure::DBforMySQL::FlexibleServer", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -659,16 +677,18 @@ def check_mysql_tls_version( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-mysql-tls-version", - title="Unable to check MySQL TLS version", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="Azure::DBforMySQL::FlexibleServer", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-mysql-tls-version", + title="Unable to check MySQL TLS version", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="Azure::DBforMySQL::FlexibleServer", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -704,14 +724,16 @@ def check_mysql_audit_log(client: AzureClient, subscription_id: str, region: str ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-mysql-audit-log", - title="Unable to check MySQL audit log", - description=f"API call failed: {e}", - domain=CheckDomain.LOGGING, - resource_type="Azure::DBforMySQL::FlexibleServer", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-mysql-audit-log", + title="Unable to check MySQL audit log", + description=f"API call failed: {e}", + domain=CheckDomain.LOGGING, + resource_type="Azure::DBforMySQL::FlexibleServer", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings diff --git a/src/shasta/azure/diagnostic_settings.py b/src/shasta/azure/diagnostic_settings.py index 72ee4cd..e25ba2c 100644 --- a/src/shasta/azure/diagnostic_settings.py +++ b/src/shasta/azure/diagnostic_settings.py @@ -65,16 +65,18 @@ def run_all_azure_diagnostic_settings_checks(client: AzureClient) -> list[Findin from azure.mgmt.monitor import MonitorManagementClient from azure.mgmt.resource import ResourceManagementClient except ImportError: - return [Finding.not_assessed( - check_id="azure-diagnostic-settings", - title="Unable to check diagnostic settings (SDK not installed)", - description="azure-mgmt-monitor or azure-mgmt-resource package not installed.", - domain=CheckDomain.LOGGING, - resource_type="Azure::Monitor::DiagnosticSetting", - account_id=sub_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-diagnostic-settings", + title="Unable to check diagnostic settings (SDK not installed)", + description="azure-mgmt-monitor or azure-mgmt-resource package not installed.", + domain=CheckDomain.LOGGING, + resource_type="Azure::Monitor::DiagnosticSetting", + account_id=sub_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] monitor = client.mgmt_client(MonitorManagementClient) rm = client.mgmt_client(ResourceManagementClient) diff --git a/src/shasta/azure/encryption.py b/src/shasta/azure/encryption.py index 2899faf..7a3dabc 100644 --- a/src/shasta/azure/encryption.py +++ b/src/shasta/azure/encryption.py @@ -623,16 +623,18 @@ def check_sql_entra_admin(client: AzureClient, subscription_id: str, region: str ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-sql-entra-admin", - title="Unable to check SQL Server Entra ID admin", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="Azure::Sql::Server", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-sql-entra-admin", + title="Unable to check SQL Server Entra ID admin", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="Azure::Sql::Server", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -688,16 +690,18 @@ def check_sql_min_tls(client: AzureClient, subscription_id: str, region: str) -> ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-sql-min-tls", - title="Unable to check SQL Server minimum TLS version", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="Azure::Sql::Server", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-sql-min-tls", + title="Unable to check SQL Server minimum TLS version", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="Azure::Sql::Server", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -768,16 +772,18 @@ def check_keyvault_rbac_mode( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-keyvault-rbac-mode", - title="Unable to check Key Vault RBAC mode", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="Azure::KeyVault::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-keyvault-rbac-mode", + title="Unable to check Key Vault RBAC mode", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="Azure::KeyVault::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -843,16 +849,18 @@ def check_keyvault_public_access( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-keyvault-public-access", - title="Unable to check Key Vault public access", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="Azure::KeyVault::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-keyvault-public-access", + title="Unable to check Key Vault public access", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="Azure::KeyVault::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings @@ -943,14 +951,16 @@ def check_keyvault_key_expiry( ) ) except Exception as e: - return [Finding.not_assessed( - check_id="azure-keyvault-key-expiry", - title="Unable to check Key Vault key/secret expiry", - description=f"API call failed: {e}", - domain=CheckDomain.ENCRYPTION, - resource_type="Azure::KeyVault::Vault", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-keyvault-key-expiry", + title="Unable to check Key Vault key/secret expiry", + description=f"API call failed: {e}", + domain=CheckDomain.ENCRYPTION, + resource_type="Azure::KeyVault::Vault", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] return findings diff --git a/src/shasta/azure/governance.py b/src/shasta/azure/governance.py index 9846c06..fee005c 100644 --- a/src/shasta/azure/governance.py +++ b/src/shasta/azure/governance.py @@ -70,16 +70,18 @@ def check_management_group_hierarchy( mcsb_controls=["GS-1"], ) ] - return [Finding.not_assessed( - check_id="azure-management-group-hierarchy", - title="Unable to check management group hierarchy", - description=f"API call failed: {e}", - domain=CheckDomain.MONITORING, - resource_type="Azure::Management::Group", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-management-group-hierarchy", + title="Unable to check management group hierarchy", + description=f"API call failed: {e}", + domain=CheckDomain.MONITORING, + resource_type="Azure::Management::Group", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] # Tenant Root group is always present; we want at least one child group too non_root = [g for g in groups if (g.name or "") != (g.tenant_id or "")] @@ -142,16 +144,18 @@ def check_security_initiative_assigned( policy = client.mgmt_client(PolicyClient) assignments = list(policy.policy_assignments.list()) except Exception as e: - return [Finding.not_assessed( - check_id="azure-security-initiative", - title="Unable to check security initiative assignments", - description=f"API call failed: {e}", - domain=CheckDomain.MONITORING, - resource_type="Azure::Policy::Assignment", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-security-initiative", + title="Unable to check security initiative assignments", + description=f"API call failed: {e}", + domain=CheckDomain.MONITORING, + resource_type="Azure::Policy::Assignment", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] initiative_names = [a.display_name or "" for a in assignments] matched = [n for n in initiative_names if any(e in n for e in EXPECTED_INITIATIVE_NAMES)] @@ -218,16 +222,18 @@ def check_critical_resource_locks( from azure.mgmt.resource import ResourceManagementClient from azure.mgmt.resource.locks import ManagementLockClient except ImportError: - return [Finding.not_assessed( - check_id="azure-resource-locks", - title="Unable to check resource locks (SDK not installed)", - description="azure-mgmt-resource package not installed.", - domain=CheckDomain.MONITORING, - resource_type="Azure::Authorization::Lock", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-resource-locks", + title="Unable to check resource locks (SDK not installed)", + description="azure-mgmt-resource package not installed.", + domain=CheckDomain.MONITORING, + resource_type="Azure::Authorization::Lock", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] rm = client.mgmt_client(ResourceManagementClient) locks_client = client.mgmt_client(ManagementLockClient) @@ -321,16 +327,18 @@ def check_required_tags(client: AzureClient, subscription_id: str, region: str) rm = client.mgmt_client(ResourceManagementClient) groups = list(rm.resource_groups.list()) except Exception as e: - return [Finding.not_assessed( - check_id="azure-required-tags", - title="Unable to check resource group tags", - description=f"API call failed: {e}", - domain=CheckDomain.MONITORING, - resource_type="Azure::Resources::ResourceGroup", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-required-tags", + title="Unable to check resource group tags", + description=f"API call failed: {e}", + domain=CheckDomain.MONITORING, + resource_type="Azure::Resources::ResourceGroup", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] required = {"owner", "environment"} untagged: list[str] = [] diff --git a/src/shasta/azure/iam.py b/src/shasta/azure/iam.py index 290ddef..4e180c0 100644 --- a/src/shasta/azure/iam.py +++ b/src/shasta/azure/iam.py @@ -1030,16 +1030,18 @@ def check_classic_administrators( ["1.22"], ) ] - return [Finding.not_assessed( - check_id="azure-classic-admins", - title="Unable to check classic administrators", - description=f"API call failed: {e}", - domain=CheckDomain.IAM, - resource_type="Azure::Authorization::ClassicAdmin", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-classic-admins", + title="Unable to check classic administrators", + description=f"API call failed: {e}", + domain=CheckDomain.IAM, + resource_type="Azure::Authorization::ClassicAdmin", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] if not classic: return [ diff --git a/src/shasta/azure/networking.py b/src/shasta/azure/networking.py index 4c220bb..534211c 100644 --- a/src/shasta/azure/networking.py +++ b/src/shasta/azure/networking.py @@ -197,16 +197,18 @@ def check_network_watcher_per_region( vnets = list(network.virtual_networks.list_all()) watchers = list(network.network_watchers.list_all()) except Exception as e: - return [Finding.not_assessed( - check_id="azure-network-watcher-coverage", - title="Unable to check Network Watcher coverage", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::Network::NetworkWatcher", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-network-watcher-coverage", + title="Unable to check Network Watcher coverage", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::Network::NetworkWatcher", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] vnet_regions = {(v.location or "").lower() for v in vnets if v.location} watcher_regions = {(w.location or "").lower() for w in watchers if w.location} @@ -481,16 +483,18 @@ def check_nsg_default_restricted( ) except Exception as e: - findings.append(Finding.not_assessed( - check_id="azure-nsg-default-restricted", - title="Unable to check NSG default restriction", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::Network::NetworkSecurityGroup", - account_id=subscription_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )) + findings.append( + Finding.not_assessed( + check_id="azure-nsg-default-restricted", + title="Unable to check NSG default restriction", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::Network::NetworkSecurityGroup", + account_id=subscription_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ) return findings diff --git a/src/shasta/azure/private_endpoints.py b/src/shasta/azure/private_endpoints.py index 2b3dbf9..4989010 100644 --- a/src/shasta/azure/private_endpoints.py +++ b/src/shasta/azure/private_endpoints.py @@ -112,16 +112,18 @@ def _walk_storage(client: AzureClient, sub_id: str, region: str) -> list[Finding ) return out except Exception as e: - return [Finding.not_assessed( - check_id="azure-private-endpoint-storageaccount", - title="Unable to check Storage private endpoints", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::Storage::StorageAccount", - account_id=sub_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-private-endpoint-storageaccount", + title="Unable to check Storage private endpoints", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::Storage::StorageAccount", + account_id=sub_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] def _walk_keyvaults(client: AzureClient, sub_id: str, region: str) -> list[Finding]: @@ -148,16 +150,18 @@ def _walk_keyvaults(client: AzureClient, sub_id: str, region: str) -> list[Findi ) return out except Exception as e: - return [Finding.not_assessed( - check_id="azure-private-endpoint-vault", - title="Unable to check Key Vault private endpoints", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::KeyVault::Vault", - account_id=sub_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-private-endpoint-vault", + title="Unable to check Key Vault private endpoints", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::KeyVault::Vault", + account_id=sub_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] def _walk_sql_servers(client: AzureClient, sub_id: str, region: str) -> list[Finding]: @@ -189,16 +193,18 @@ def _walk_sql_servers(client: AzureClient, sub_id: str, region: str) -> list[Fin ) return out except Exception as e: - return [Finding.not_assessed( - check_id="azure-private-endpoint-server", - title="Unable to check SQL Server private endpoints", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::Sql::Server", - account_id=sub_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-private-endpoint-server", + title="Unable to check SQL Server private endpoints", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::Sql::Server", + account_id=sub_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] def _walk_cosmos(client: AzureClient, sub_id: str, region: str) -> list[Finding]: @@ -223,16 +229,18 @@ def _walk_cosmos(client: AzureClient, sub_id: str, region: str) -> list[Finding] ) return out except Exception as e: - return [Finding.not_assessed( - check_id="azure-private-endpoint-databaseaccount", - title="Unable to check Cosmos DB private endpoints", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::Cosmos::DatabaseAccount", - account_id=sub_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-private-endpoint-databaseaccount", + title="Unable to check Cosmos DB private endpoints", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::Cosmos::DatabaseAccount", + account_id=sub_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] def _walk_acr(client: AzureClient, sub_id: str, region: str) -> list[Finding]: @@ -262,16 +270,18 @@ def _walk_acr(client: AzureClient, sub_id: str, region: str) -> list[Finding]: ) return out except Exception as e: - return [Finding.not_assessed( - check_id="azure-private-endpoint-registry", - title="Unable to check Container Registry private endpoints", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::ContainerRegistry::Registry", - account_id=sub_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-private-endpoint-registry", + title="Unable to check Container Registry private endpoints", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::ContainerRegistry::Registry", + account_id=sub_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] def _walk_app_service(client: AzureClient, sub_id: str, region: str) -> list[Finding]: @@ -303,16 +313,18 @@ def _walk_app_service(client: AzureClient, sub_id: str, region: str) -> list[Fin ) return out except Exception as e: - return [Finding.not_assessed( - check_id="azure-private-endpoint-site", - title="Unable to check App Service private endpoints", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::Web::Site", - account_id=sub_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-private-endpoint-site", + title="Unable to check App Service private endpoints", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::Web::Site", + account_id=sub_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] def _walk_cognitive(client: AzureClient, sub_id: str, region: str) -> list[Finding]: @@ -338,13 +350,15 @@ def _walk_cognitive(client: AzureClient, sub_id: str, region: str) -> list[Findi ) return out except Exception as e: - return [Finding.not_assessed( - check_id="azure-private-endpoint-account", - title="Unable to check Cognitive Services private endpoints", - description=f"API call failed: {e}", - domain=CheckDomain.NETWORKING, - resource_type="Azure::CognitiveServices::Account", - account_id=sub_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-private-endpoint-account", + title="Unable to check Cognitive Services private endpoints", + description=f"API call failed: {e}", + domain=CheckDomain.NETWORKING, + resource_type="Azure::CognitiveServices::Account", + account_id=sub_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] diff --git a/src/shasta/azure/storage.py b/src/shasta/azure/storage.py index 26d2f49..f27be99 100644 --- a/src/shasta/azure/storage.py +++ b/src/shasta/azure/storage.py @@ -105,7 +105,6 @@ def run_all_azure_storage_checks(client: AzureClient) -> list[Finding]: def _check_storage_encryption(account, name, acct_id, rg, sub_id, region) -> list[Finding]: """[CC6.7] Check storage account encryption at rest.""" # Azure enforces SSE by default, but check TLS version - encryption = account.encryption min_tls = account.minimum_tls_version or "TLS1_0" if min_tls in ("TLS1_2", "TLS1_3"): @@ -316,16 +315,18 @@ def _check_storage_soft_delete( ] except Exception as e: - return [Finding.not_assessed( - check_id="azure-storage-soft-delete", - title="Unable to check storage soft delete", - description=f"API call failed: {e}", - domain=CheckDomain.STORAGE, - resource_type="Azure::Storage::StorageAccount", - account_id=sub_id, - region=region, - cloud_provider=CloudProvider.AZURE, - )] + return [ + Finding.not_assessed( + check_id="azure-storage-soft-delete", + title="Unable to check storage soft delete", + description=f"API call failed: {e}", + domain=CheckDomain.STORAGE, + resource_type="Azure::Storage::StorageAccount", + account_id=sub_id, + region=region, + cloud_provider=CloudProvider.AZURE, + ) + ] def _check_shared_key_access(account, name, acct_id, rg, sub_id, region) -> list[Finding]: diff --git a/src/shasta/compliance/_status.py b/src/shasta/compliance/_status.py index 4089e45..59d9501 100644 --- a/src/shasta/compliance/_status.py +++ b/src/shasta/compliance/_status.py @@ -24,7 +24,9 @@ from __future__ import annotations -_ControlAggregate = dict # keys: fail_count, partial_count, pass_count, requires_policy, has_automated_checks +_ControlAggregate = ( + dict # keys: fail_count, partial_count, pass_count, requires_policy, has_automated_checks +) def decide_control_status(data: _ControlAggregate) -> str: diff --git a/src/shasta/compliance/ai/mapper.py b/src/shasta/compliance/ai/mapper.py index 732c760..6ad150c 100644 --- a/src/shasta/compliance/ai/mapper.py +++ b/src/shasta/compliance/ai/mapper.py @@ -2,7 +2,6 @@ from __future__ import annotations -from shasta.evidence.models import Finding from shasta.compliance.ai.eu_ai_act import ( EU_AI_ACT_OBLIGATIONS, get_eu_ai_act_obligations_for_check, @@ -31,6 +30,7 @@ OWASP_LLM_TOP10, get_owasp_llm_risks_for_check, ) +from shasta.evidence.models import Finding def enrich_findings_with_ai_controls(findings: list[Finding]) -> list[Finding]: diff --git a/src/shasta/compliance/ai/nist_ai_rmf.py b/src/shasta/compliance/ai/nist_ai_rmf.py index e8c42dc..a7fdb4a 100644 --- a/src/shasta/compliance/ai/nist_ai_rmf.py +++ b/src/shasta/compliance/ai/nist_ai_rmf.py @@ -91,10 +91,7 @@ class NISTAIRMFCategory: ), requires_policy=True, soc2_equivalent=["CC1.1", "CC2.1"], - guidance=( - "Foster risk-aware culture through training and the AI " - "Acceptable Use Policy." - ), + guidance=("Foster risk-aware culture through training and the AI Acceptable Use Policy."), ), "GOVERN-5": NISTAIRMFCategory( id="GOVERN-5", @@ -340,8 +337,7 @@ class NISTAIRMFCategory: function="MANAGE", title="Third-Party Risk Management", description=( - "AI risks from third-party resources and tools are managed " - "throughout the lifecycle." + "AI risks from third-party resources and tools are managed throughout the lifecycle." ), check_ids=[ "code-outdated-ai-sdk", diff --git a/src/shasta/compliance/ai/scorer.py b/src/shasta/compliance/ai/scorer.py index 7fa4051..285fa1f 100644 --- a/src/shasta/compliance/ai/scorer.py +++ b/src/shasta/compliance/ai/scorer.py @@ -4,7 +4,6 @@ from dataclasses import dataclass -from shasta.evidence.models import Finding from shasta.compliance.ai.mapper import ( get_eu_ai_act_obligation_summary, get_iso42001_control_summary, @@ -14,6 +13,7 @@ get_owasp_agentic_summary, get_owasp_llm_summary, ) +from shasta.evidence.models import Finding @dataclass diff --git a/src/shasta/compliance/framework.py b/src/shasta/compliance/framework.py index 7155b7a..0ae6c9a 100644 --- a/src/shasta/compliance/framework.py +++ b/src/shasta/compliance/framework.py @@ -7,10 +7,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from enum import Enum +from enum import StrEnum -class ControlCategory(str, Enum): +class ControlCategory(StrEnum): """SOC 2 Trust Service Categories.""" SECURITY = "Security" diff --git a/src/shasta/compliance/hipaa.py b/src/shasta/compliance/hipaa.py index 534454e..3c5f646 100644 --- a/src/shasta/compliance/hipaa.py +++ b/src/shasta/compliance/hipaa.py @@ -17,10 +17,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from enum import Enum +from enum import StrEnum -class HIPAASafeguard(str, Enum): +class HIPAASafeguard(StrEnum): """HIPAA Security Rule safeguard categories.""" ADMINISTRATIVE = "Administrative" diff --git a/src/shasta/compliance/iso27001.py b/src/shasta/compliance/iso27001.py index c66cc15..a09a91f 100644 --- a/src/shasta/compliance/iso27001.py +++ b/src/shasta/compliance/iso27001.py @@ -16,10 +16,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from enum import Enum +from enum import StrEnum -class ISO27001Theme(str, Enum): +class ISO27001Theme(StrEnum): """ISO 27001:2022 control themes.""" ORGANIZATIONAL = "Organizational" diff --git a/src/shasta/compliance/testing.py b/src/shasta/compliance/testing.py index 8c0087b..e9ecf47 100644 --- a/src/shasta/compliance/testing.py +++ b/src/shasta/compliance/testing.py @@ -17,7 +17,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -247,7 +247,7 @@ class ControlTestSuite: def generate_control_tests(scan: ScanResult) -> ControlTestSuite: """Generate auditor-grade control tests from scan findings.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) findings_by_check = _index_findings(scan.findings) tests = [] @@ -385,12 +385,12 @@ def save_control_test_report( f"**{labels['account_label']}:** {suite.account_id}", f"**Test Period:** {suite.test_period}", f"**Tested At:** {suite.tested_at}", - f"**Tested By:** Shasta Automated Compliance Testing", + "**Tested By:** Shasta Automated Compliance Testing", "", "## Summary", "", - f"| Result | Count |", - f"|--------|-------|", + "| Result | Count |", + "|--------|-------|", f"| Total Tests | {suite.total_tests} |", f"| Passed | {suite.passed} |", f"| Failed | {suite.failed} |", diff --git a/src/shasta/dashboard/__main__.py b/src/shasta/dashboard/__main__.py index 35a23b6..cd5fc83 100644 --- a/src/shasta/dashboard/__main__.py +++ b/src/shasta/dashboard/__main__.py @@ -51,9 +51,7 @@ def main() -> int: ) return 2 - console.print( - f"Shasta Dashboard starting at [cyan]http://{args.host}:{args.port}[/cyan]" - ) + console.print(f"Shasta Dashboard starting at [cyan]http://{args.host}:{args.port}[/cyan]") uvicorn.run(app, host=args.host, port=args.port) return 0 diff --git a/src/shasta/evidence/collector.py b/src/shasta/evidence/collector.py index d90911e..2d266c6 100644 --- a/src/shasta/evidence/collector.py +++ b/src/shasta/evidence/collector.py @@ -13,7 +13,7 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -80,7 +80,7 @@ def collect_all_evidence( manifest = { "scan_id": scan_id, "account_id": account_id, - "collected_at": datetime.now(timezone.utc).isoformat(), + "collected_at": datetime.now(UTC).isoformat(), "artifacts": [str(f.name) for f in saved_files], } manifest_path = scan_dir / "manifest.json" diff --git a/src/shasta/evidence/models.py b/src/shasta/evidence/models.py index ac211ac..3cfd097 100644 --- a/src/shasta/evidence/models.py +++ b/src/shasta/evidence/models.py @@ -3,14 +3,14 @@ from __future__ import annotations from datetime import UTC, datetime -from enum import Enum +from enum import StrEnum from typing import Any from uuid import uuid4 from pydantic import BaseModel, Field -class Severity(str, Enum): +class Severity(StrEnum): """Finding severity levels.""" CRITICAL = "critical" @@ -20,7 +20,7 @@ class Severity(str, Enum): INFO = "info" -class ComplianceStatus(str, Enum): +class ComplianceStatus(StrEnum): """Compliance status for a control or finding.""" PASS = "pass" @@ -30,7 +30,7 @@ class ComplianceStatus(str, Enum): NOT_APPLICABLE = "not_applicable" -class CloudProvider(str, Enum): +class CloudProvider(StrEnum): """Cloud provider for a finding or scan.""" AWS = "aws" @@ -38,7 +38,7 @@ class CloudProvider(str, Enum): GCP = "gcp" -class CheckDomain(str, Enum): +class CheckDomain(StrEnum): """Compliance check domains (cloud-agnostic).""" IAM = "iam" @@ -66,12 +66,12 @@ def not_assessed( check_id: str, title: str, description: str, - domain: "CheckDomain", + domain: CheckDomain, resource_type: str, account_id: str, region: str, - cloud_provider: "CloudProvider" = CloudProvider.AWS, # type: ignore[assignment] - ) -> "Finding": + cloud_provider: CloudProvider = CloudProvider.AWS, # type: ignore[assignment] + ) -> Finding: """Create a NOT_ASSESSED finding for when an API call fails. Use this instead of returning an empty list on error — empty lists @@ -91,6 +91,7 @@ def not_assessed( account_id=account_id, cloud_provider=cloud_provider, ) + check_id: str # e.g., "iam-mfa-enabled", "azure-nsg-unrestricted-ingress" title: str # Human-readable title description: str # What was found @@ -110,7 +111,9 @@ def not_assessed( cis_gcp_controls: list[str] = Field(default_factory=list) # e.g., ["1.4", "3.6", "5.1"] mcsb_controls: list[str] = Field(default_factory=list) # e.g., ["IM-6", "DP-5"] iso27001_controls: list[str] = Field(default_factory=list) # e.g., ["A.8.5", "A.5.15"] - hipaa_controls: list[str] = Field(default_factory=list) # e.g., ["164.312(a)(1)", "164.312(e)(1)"] + hipaa_controls: list[str] = Field( + default_factory=list + ) # e.g., ["164.312(a)(1)", "164.312(e)(1)"] timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/src/shasta/gcp/client.py b/src/shasta/gcp/client.py index ed216b4..52629a3 100644 --- a/src/shasta/gcp/client.py +++ b/src/shasta/gcp/client.py @@ -246,7 +246,7 @@ def list_projects(self) -> list[dict[str, str]]: {"project_id": pid, "display_name": "", "project_number": "", "state": "ACTIVE"} ] - def for_project(self, project_id: str) -> "GCPClient": + def for_project(self, project_id: str) -> GCPClient: """Return a sibling GCPClient bound to a different project. Reuses the same credentials and clears the service cache so each project @@ -259,7 +259,7 @@ def for_project(self, project_id: str) -> "GCPClient": ) return sibling - def for_region(self, region: str) -> "GCPClient": + def for_region(self, region: str) -> GCPClient: """Return a sibling GCPClient scoped to a specific region. The region is stored for use by regional API calls (subnets, instances). diff --git a/src/shasta/gcp/cloud_run.py b/src/shasta/gcp/cloud_run.py index 5c51214..580e7f0 100644 --- a/src/shasta/gcp/cloud_run.py +++ b/src/shasta/gcp/cloud_run.py @@ -16,7 +16,6 @@ import re from typing import Any -from shasta.gcp.client import GCPClient from shasta.evidence.models import ( CheckDomain, CloudProvider, @@ -24,6 +23,7 @@ Finding, Severity, ) +from shasta.gcp.client import GCPClient IS_GLOBAL = False # Cloud Run services are regional @@ -50,8 +50,12 @@ def run_all_gcp_cloud_run_checks(client: GCPClient) -> list[Finding]: findings: list[Finding] = [] for region in client.get_enabled_regions(): regional_client = client.for_region(region) - findings.extend(check_cloud_run_no_unauthenticated_access(regional_client, project_id, region)) - findings.extend(check_cloud_run_no_default_service_account(regional_client, project_id, region)) + findings.extend( + check_cloud_run_no_unauthenticated_access(regional_client, project_id, region) + ) + findings.extend( + check_cloud_run_no_default_service_account(regional_client, project_id, region) + ) findings.extend(check_cloud_run_ingress_restricted(regional_client, project_id, region)) findings.extend(check_cloud_run_binary_authorization(regional_client, project_id, region)) findings.extend(check_cloud_run_no_plaintext_secrets(regional_client, project_id, region)) @@ -59,7 +63,9 @@ def run_all_gcp_cloud_run_checks(client: GCPClient) -> list[Finding]: return findings -def _list_cloud_run_services(client: GCPClient, project_id: str, region: str) -> list[dict[str, Any]]: +def _list_cloud_run_services( + client: GCPClient, project_id: str, region: str +) -> list[dict[str, Any]]: """Return all Cloud Run v2 services in the given region.""" run = client.service("run", "v2") parent = f"projects/{project_id}/locations/{region}" @@ -70,11 +76,7 @@ def _list_cloud_run_services(client: GCPClient, project_id: str, region: str) -> next_page = response.get("nextPageToken") while next_page: response = ( - run.projects() - .locations() - .services() - .list(parent=parent, pageToken=next_page) - .execute() + run.projects().locations().services().list(parent=parent, pageToken=next_page).execute() ) services.extend(response.get("services", [])) next_page = response.get("nextPageToken") @@ -118,13 +120,7 @@ def check_cloud_run_no_unauthenticated_access( for svc in services: svc_name = svc.get("name", "") try: - policy = ( - run.projects() - .locations() - .services() - .getIamPolicy(resource=svc_name) - .execute() - ) + policy = run.projects().locations().services().getIamPolicy(resource=svc_name).execute() for binding in policy.get("bindings", []): members = binding.get("members", []) if "allUsers" in members or "allAuthenticatedUsers" in members: diff --git a/src/shasta/gcp/compute.py b/src/shasta/gcp/compute.py index c74b7d1..747acae 100644 --- a/src/shasta/gcp/compute.py +++ b/src/shasta/gcp/compute.py @@ -10,9 +10,6 @@ from __future__ import annotations -from typing import Any - -from shasta.gcp.client import GCPClient from shasta.evidence.models import ( CheckDomain, CloudProvider, @@ -20,6 +17,7 @@ Finding, Severity, ) +from shasta.gcp.client import GCPClient IS_GLOBAL = False # Compute instance and GKE cluster checks are per-region @@ -42,7 +40,11 @@ def run_all_gcp_compute_checks(client: GCPClient) -> list[Finding]: for region in client.get_enabled_regions(): regional_client = client.for_region(region) findings.extend(check_instance_no_external_ip(regional_client, project_id, region)) - findings.extend(check_instance_no_default_service_account_full_scope(regional_client, project_id, region)) + findings.extend( + check_instance_no_default_service_account_full_scope( + regional_client, project_id, region + ) + ) findings.extend(check_instance_shielded_vm(regional_client, project_id, region)) findings.extend(check_gke_private_cluster(regional_client, project_id, region)) findings.extend(check_gke_workload_identity(regional_client, project_id, region)) @@ -52,9 +54,7 @@ def run_all_gcp_compute_checks(client: GCPClient) -> list[Finding]: return findings -def check_os_login_project_enabled( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_os_login_project_enabled(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 4.4] OS Login should be enabled at the project level. OS Login ties SSH access to Google Account credentials and supports @@ -139,9 +139,7 @@ def check_os_login_project_enabled( ] -def check_serial_port_disabled_project( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_serial_port_disabled_project(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 4.5] Serial port access should be disabled at the project level. The serial console provides interactive access to a VM without SSH and bypasses @@ -219,9 +217,7 @@ def check_serial_port_disabled_project( ] -def check_instance_no_external_ip( - client: GCPClient, project_id: str, region: str -) -> list[Finding]: +def check_instance_no_external_ip(client: GCPClient, project_id: str, region: str) -> list[Finding]: """[CIS 4.6] Compute Engine instances should not have external IP addresses. Instances with public IPs are directly reachable from the internet. Use @@ -336,8 +332,8 @@ def check_instance_no_default_service_account_full_scope( This violates least privilege and means any code running on the instance has full GCP project control. """ - FULL_SCOPE = "https://www.googleapis.com/auth/cloud-platform" - DEFAULT_SA_SUFFIX = "-compute@developer.gserviceaccount.com" + full_scope = "https://www.googleapis.com/auth/cloud-platform" + default_sa_suffix = "-compute@developer.gserviceaccount.com" try: compute = client.service("compute", "v1") @@ -377,8 +373,8 @@ def check_instance_no_default_service_account_full_scope( for sa in inst.get("serviceAccounts", []): email = sa.get("email", "") scopes = sa.get("scopes", []) - is_default = email.endswith(DEFAULT_SA_SUFFIX) - has_full_scope = FULL_SCOPE in scopes + is_default = email.endswith(default_sa_suffix) + has_full_scope = full_scope in scopes if is_default and has_full_scope: offenders.append( { @@ -439,9 +435,7 @@ def check_instance_no_default_service_account_full_scope( ] -def check_instance_shielded_vm( - client: GCPClient, project_id: str, region: str -) -> list[Finding]: +def check_instance_shielded_vm(client: GCPClient, project_id: str, region: str) -> list[Finding]: """[CIS 4.8] Compute instances should have Shielded VM enabled. Shielded VM provides verifiable integrity of Compute Engine VM instances using @@ -544,9 +538,7 @@ def check_instance_shielded_vm( ] -def check_gke_private_cluster( - client: GCPClient, project_id: str, region: str -) -> list[Finding]: +def check_gke_private_cluster(client: GCPClient, project_id: str, region: str) -> list[Finding]: """[CIS 7.1] GKE clusters should use private nodes (no public IP on nodes). Private clusters ensure worker nodes have no external IP addresses, preventing @@ -635,9 +627,7 @@ def check_gke_private_cluster( ] -def check_gke_workload_identity( - client: GCPClient, project_id: str, region: str -) -> list[Finding]: +def check_gke_workload_identity(client: GCPClient, project_id: str, region: str) -> list[Finding]: """[CIS 7.4] GKE clusters should use Workload Identity for GCP API access. Without Workload Identity, pods must use mounted service account key files or @@ -729,9 +719,7 @@ def check_gke_workload_identity( ] -def check_gke_network_policy( - client: GCPClient, project_id: str, region: str -) -> list[Finding]: +def check_gke_network_policy(client: GCPClient, project_id: str, region: str) -> list[Finding]: """[CIS 7.5] GKE clusters should have a Network Policy enabled. Without a network policy, all pods can communicate with all other pods across @@ -827,9 +815,7 @@ def check_gke_network_policy( ] -def check_gke_node_auto_upgrade( - client: GCPClient, project_id: str, region: str -) -> list[Finding]: +def check_gke_node_auto_upgrade(client: GCPClient, project_id: str, region: str) -> list[Finding]: """GKE node pools should have automatic node upgrades enabled. Auto-upgrade keeps node pools on the latest patched Kubernetes minor version, diff --git a/src/shasta/gcp/encryption.py b/src/shasta/gcp/encryption.py index 0308486..f34c1a0 100644 --- a/src/shasta/gcp/encryption.py +++ b/src/shasta/gcp/encryption.py @@ -10,7 +10,6 @@ from typing import Any -from shasta.gcp.client import GCPClient from shasta.evidence.models import ( CheckDomain, CloudProvider, @@ -18,6 +17,7 @@ Finding, Severity, ) +from shasta.gcp.client import GCPClient IS_GLOBAL = True # Uses global/project-wide listing endpoints for KMS, SQL, BigQuery @@ -40,9 +40,7 @@ def run_all_gcp_encryption_checks(client: GCPClient) -> list[Finding]: return findings -def check_kms_key_rotation_period( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_kms_key_rotation_period(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 1.10] Cloud KMS key rotation period should be ≤90 days. Key rotation limits the amount of data encrypted under a single key version @@ -54,17 +52,13 @@ def check_kms_key_rotation_period( kms = client.service("cloudkms", "v1") # KMS doesn't accept "-" as a wildcard location — enumerate supported # locations first, then list key rings per location. - locations_resp = ( - kms.projects().locations().list(name=f"projects/{project_id}").execute() - ) + locations_resp = kms.projects().locations().list(name=f"projects/{project_id}").execute() locations = [loc.get("locationId") for loc in locations_resp.get("locations", [])] key_rings: list[dict[str, Any]] = [] for location in locations: try: parent = f"projects/{project_id}/locations/{location}" - resp = ( - kms.projects().locations().keyRings().list(parent=parent).execute() - ) + resp = kms.projects().locations().keyRings().list(parent=parent).execute() key_rings.extend(resp.get("keyRings", [])) except Exception: # A single failed location should not abort the whole check @@ -107,12 +101,7 @@ def check_kms_key_rotation_period( ring_name = ring.get("name", "") try: keys_resp = ( - kms.projects() - .locations() - .keyRings() - .cryptoKeys() - .list(parent=ring_name) - .execute() + kms.projects().locations().keyRings().cryptoKeys().list(parent=ring_name).execute() ) keys = keys_resp.get("cryptoKeys", []) except Exception: @@ -123,7 +112,7 @@ def check_kms_key_rotation_period( if purpose != "ENCRYPT_DECRYPT": continue # Only symmetric encryption keys can be rotated rotation_period = key.get("rotationPeriod") - next_rotation = key.get("nextRotationTime") + key.get("nextRotationTime") if not rotation_period: offenders.append( @@ -195,9 +184,7 @@ def check_kms_key_rotation_period( ] -def check_sql_require_ssl( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_sql_require_ssl(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 6.3] Cloud SQL instances should require SSL for all connections. Without enforced SSL, database clients can connect over unencrypted channels, @@ -305,9 +292,7 @@ def check_sql_require_ssl( ] -def check_sql_no_public_ip( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_sql_no_public_ip(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 6.2] Cloud SQL instances should not have a public IP address. A public IP makes the database endpoint directly reachable from the internet. @@ -397,9 +382,7 @@ def check_sql_no_public_ip( ] -def check_sql_data_backup_enabled( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_sql_data_backup_enabled(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 6.7] Cloud SQL instances should have automated backups enabled. Automated backups are a last resort recovery mechanism. Without them, data @@ -488,9 +471,7 @@ def check_sql_data_backup_enabled( ] -def check_bigquery_no_public_access( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_bigquery_no_public_access(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 7.1] BigQuery datasets should not be publicly accessible. Public datasets expose all tables within them to the internet, bypassing @@ -538,17 +519,11 @@ def check_bigquery_no_public_access( for ds_ref in datasets: dataset_id = ds_ref.get("datasetReference", {}).get("datasetId", "") try: - dataset = ( - bq.datasets() - .get(projectId=project_id, datasetId=dataset_id) - .execute() - ) + dataset = bq.datasets().get(projectId=project_id, datasetId=dataset_id).execute() for entry in dataset.get("access", []): special = entry.get("specialGroup", "") if special in ("allUsers", "allAuthenticatedUsers"): - public_datasets.append( - {"dataset": dataset_id, "special_group": special} - ) + public_datasets.append({"dataset": dataset_id, "special_group": special}) break except Exception: continue @@ -601,9 +576,7 @@ def check_bigquery_no_public_access( ] -def check_bigquery_cmek_configured( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_bigquery_cmek_configured(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 7.2] BigQuery datasets with sensitive data should use Customer-Managed Encryption Keys. By default BigQuery uses Google-managed keys. CMEK gives you control over key @@ -636,11 +609,7 @@ def check_bigquery_cmek_configured( for ds_ref in datasets: dataset_id = ds_ref.get("datasetReference", {}).get("datasetId", "") try: - dataset = ( - bq.datasets() - .get(projectId=project_id, datasetId=dataset_id) - .execute() - ) + dataset = bq.datasets().get(projectId=project_id, datasetId=dataset_id).execute() # defaultEncryptionConfiguration is only set when CMEK is configured if not dataset.get("defaultEncryptionConfiguration", {}).get("kmsKeyName"): missing_cmek.append(dataset_id) diff --git a/src/shasta/gcp/iam.py b/src/shasta/gcp/iam.py index 1584a1e..c48e1df 100644 --- a/src/shasta/gcp/iam.py +++ b/src/shasta/gcp/iam.py @@ -10,10 +10,9 @@ from __future__ import annotations -from datetime import datetime, timezone, timedelta +from datetime import UTC, datetime, timedelta from typing import Any -from shasta.gcp.client import GCPClient from shasta.evidence.models import ( CheckDomain, CloudProvider, @@ -21,6 +20,7 @@ Finding, Severity, ) +from shasta.gcp.client import GCPClient IS_GLOBAL = True # IAM is project-level — no per-region iteration @@ -97,7 +97,7 @@ def check_service_account_key_rotation( ) ] - now = datetime.now(timezone.utc) + now = datetime.now(UTC) threshold = now - timedelta(days=SA_KEY_MAX_AGE_DAYS) stale: list[dict[str, Any]] = [] access_errors: list[dict[str, str]] = [] @@ -479,7 +479,7 @@ def check_iam_service_account_token_creator( resource-level bindings, not at the project level — regardless of principal type. A compromised service account holder has the same blast radius as a compromised user. """ - IMPERSONATION_ROLES = { + impersonation_roles = { "roles/iam.serviceAccountTokenCreator", "roles/iam.serviceAccountUser", } @@ -505,7 +505,7 @@ def check_iam_service_account_token_creator( offenders: list[dict[str, Any]] = [] for binding in bindings: role = binding.get("role", "") - if role not in IMPERSONATION_ROLES: + if role not in impersonation_roles: continue members = binding.get("members", []) if members: diff --git a/src/shasta/gcp/logging_checks.py b/src/shasta/gcp/logging_checks.py index 5b469f4..6576449 100644 --- a/src/shasta/gcp/logging_checks.py +++ b/src/shasta/gcp/logging_checks.py @@ -11,7 +11,6 @@ from typing import Any -from shasta.gcp.client import GCPClient from shasta.evidence.models import ( CheckDomain, CloudProvider, @@ -19,6 +18,7 @@ Finding, Severity, ) +from shasta.gcp.client import GCPClient IS_GLOBAL = True # Audit log configuration is project-wide @@ -26,12 +26,24 @@ # Each tuple: (filter_fragment, human_description, cis_id) _CIS_LOG_METRICS: list[tuple[str, str, str]] = [ ("protoPayload.methodName:SetIamPolicy", "IAM policy changes", "2.2"), - ("resource.type=audited_resource AND protoPayload.serviceName=cloudresourcemanager", "project ownership changes", "2.4"), + ( + "resource.type=audited_resource AND protoPayload.serviceName=cloudresourcemanager", + "project ownership changes", + "2.4", + ), ("resource.type=gce_firewall_rule", "VPC firewall rule changes", "2.10"), ("resource.type=gce_network", "VPC network changes", "2.9"), ("resource.type=gce_route", "VPC route changes", "2.8"), - ("resource.type=iam_role AND protoPayload.methodName:(roles.create OR roles.update OR roles.delete)", "custom IAM role changes", "2.3"), - ("protoPayload.methodName=google.logging.v2.ConfigServiceV2.UpdateSink", "audit logging sink changes", "2.5"), + ( + "resource.type=iam_role AND protoPayload.methodName:(roles.create OR roles.update OR roles.delete)", + "custom IAM role changes", + "2.3", + ), + ( + "protoPayload.methodName=google.logging.v2.ConfigServiceV2.UpdateSink", + "audit logging sink changes", + "2.5", + ), ("resource.type=bigquery_dataset", "BigQuery IAM changes", "2.13"), ] @@ -53,9 +65,7 @@ def run_all_gcp_logging_checks(client: GCPClient) -> list[Finding]: return findings -def check_audit_config_data_access( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_audit_config_data_access(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 2.1] Data Access audit logs should be configured for all services. By default, GCP only writes Admin Activity logs. Data Access logs (DATA_READ, @@ -144,14 +154,15 @@ def check_audit_config_data_access( cis_gcp_controls=["2.1"], iso27001_controls=["A.8.15"], hipaa_controls=["164.312(b)"], - details={"missing_log_types": sorted(missing_types), "current_audit_configs": list(all_services_types)}, + details={ + "missing_log_types": sorted(missing_types), + "current_audit_configs": list(all_services_types), + }, ) ] -def check_log_sink_configured( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_log_sink_configured(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 2.2] At least one log sink should export project logs to an external destination. Log sinks export Cloud Logging entries to Cloud Storage, BigQuery, or Pub/Sub. @@ -180,7 +191,8 @@ def check_log_sink_configured( # Filter to non-_Default sinks that have a real destination active_export_sinks = [ - s for s in sinks + s + for s in sinks if s.get("name", "").split("/")[-1] not in ("_Default", "_Required") and s.get("destination") ] @@ -239,9 +251,7 @@ def check_log_sink_configured( ] -def check_log_metrics_and_alerts( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_log_metrics_and_alerts(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 2.2–2.13] Log-based metrics and alert policies should exist for key audit events. CIS GCP requires log-based metrics + Alerting policies for IAM changes, custom role @@ -253,19 +263,11 @@ def check_log_metrics_and_alerts( logging = client.service("logging", "v2") monitoring = client.service("monitoring", "v3") - metrics_resp = ( - logging.projects() - .metrics() - .list(parent=f"projects/{project_id}") - .execute() - ) + metrics_resp = logging.projects().metrics().list(parent=f"projects/{project_id}").execute() metrics = metrics_resp.get("metrics", []) policies_resp = ( - monitoring.projects() - .alertPolicies() - .list(name=f"projects/{project_id}") - .execute() + monitoring.projects().alertPolicies().list(name=f"projects/{project_id}").execute() ) policies = policies_resp.get("alertPolicies", []) except Exception as e: @@ -333,7 +335,7 @@ def check_log_metrics_and_alerts( description=( f"{len(missing)} CIS-required log-based metrics do not exist. Missing: " + "; ".join(f"CIS {m['cis_id']} ({m['description']})" for m in missing[:5]) - + (f" and {len(missing)-5} more" if len(missing) > 5 else "") + + (f" and {len(missing) - 5} more" if len(missing) > 5 else "") + ". Without these metrics, security events like IAM changes and firewall " "modifications go undetected in real time." ), @@ -358,9 +360,7 @@ def check_log_metrics_and_alerts( ] -def check_log_retention_period( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_log_retention_period(client: GCPClient, project_id: str) -> list[Finding]: """Log buckets should retain logs for at least 365 days. Cloud Logging default retention is 30 days for most log types. For SOC 2 and @@ -368,7 +368,7 @@ def check_log_retention_period( _Default and _Required log buckets have sufficient retention configured. """ region = "global" - MIN_RETENTION_DAYS = 365 + min_retention_days = 365 try: logging = client.service("logging", "v2") @@ -401,12 +401,12 @@ def check_log_retention_period( for bucket in buckets: bucket_name = bucket.get("name", "").split("/")[-1] retention_days = bucket.get("retentionDays", 30) - if retention_days < MIN_RETENTION_DAYS: + if retention_days < min_retention_days: insufficient.append( { "bucket": bucket_name, "retention_days": retention_days, - "required_days": MIN_RETENTION_DAYS, + "required_days": min_retention_days, } ) @@ -414,8 +414,8 @@ def check_log_retention_period( return [ Finding( check_id="gcp-log-retention", - title=f"All Cloud Logging buckets retain logs ≥{MIN_RETENTION_DAYS} days", - description=f"All Cloud Logging buckets have retention ≥{MIN_RETENTION_DAYS} days configured.", + title=f"All Cloud Logging buckets retain logs ≥{min_retention_days} days", + description=f"All Cloud Logging buckets have retention ≥{min_retention_days} days configured.", severity=Severity.INFO, status=ComplianceStatus.PASS, domain=CheckDomain.MONITORING, @@ -432,9 +432,9 @@ def check_log_retention_period( return [ Finding( check_id="gcp-log-retention", - title=f"{len(insufficient)} Cloud Logging bucket(s) retain logs <{MIN_RETENTION_DAYS} days", + title=f"{len(insufficient)} Cloud Logging bucket(s) retain logs <{min_retention_days} days", description=( - f"{len(insufficient)} logging bucket(s) have retention below {MIN_RETENTION_DAYS} " + f"{len(insufficient)} logging bucket(s) have retention below {min_retention_days} " "days. SOC 2 and HIPAA require at least 1 year of audit log retention." ), severity=Severity.MEDIUM, @@ -447,7 +447,7 @@ def check_log_retention_period( cloud_provider=CloudProvider.GCP, remediation=( "Update retention: `gcloud logging buckets update BUCKET_NAME " - f"--location=LOCATION --retention-days={MIN_RETENTION_DAYS} " + f"--location=LOCATION --retention-days={min_retention_days} " f"--project={project_id}`." ), soc2_controls=["CC7.1"], @@ -459,9 +459,7 @@ def check_log_retention_period( ] -def check_log_metric_vpc_network_changes( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_log_metric_vpc_network_changes(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 2.9] A log-based metric should exist for VPC network configuration changes. VPC network changes (creating/deleting networks, routes, peering) are significant @@ -477,9 +475,7 @@ def check_log_metric_vpc_network_changes( ) -def check_log_metric_firewall_changes( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_log_metric_firewall_changes(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 2.10] A log-based metric should exist for VPC firewall rule changes. Unauthorized firewall changes can open the network perimeter. Real-time @@ -495,9 +491,7 @@ def check_log_metric_firewall_changes( ) -def check_log_metric_custom_role_changes( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_log_metric_custom_role_changes(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 2.3] A log-based metric should exist for custom IAM role changes. Custom role mutations (adding permissions, creating new roles) are a common @@ -513,9 +507,7 @@ def check_log_metric_custom_role_changes( ) -def check_log_metric_project_ownership( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_log_metric_project_ownership(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 2.4] A log-based metric should exist for project ownership (IAM policy) changes. Changes to project-level IAM policy are the most impactful security events — @@ -543,12 +535,7 @@ def _check_single_log_metric( region = "global" try: logging = client.service("logging", "v2") - response = ( - logging.projects() - .metrics() - .list(parent=f"projects/{project_id}") - .execute() - ) + response = logging.projects().metrics().list(parent=f"projects/{project_id}").execute() metrics = response.get("metrics", []) except Exception as e: return [ diff --git a/src/shasta/gcp/networking.py b/src/shasta/gcp/networking.py index 782344f..3fced64 100644 --- a/src/shasta/gcp/networking.py +++ b/src/shasta/gcp/networking.py @@ -12,7 +12,6 @@ from typing import Any -from shasta.gcp.client import GCPClient from shasta.evidence.models import ( CheckDomain, CloudProvider, @@ -20,6 +19,7 @@ Finding, Severity, ) +from shasta.gcp.client import GCPClient IS_GLOBAL = False # Subnet flow-log and Private Google Access checks are per-region @@ -68,9 +68,7 @@ def run_all_gcp_networking_checks(client: GCPClient) -> list[Finding]: return findings -def check_default_network_not_created( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_default_network_not_created(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 3.1] The default VPC network should be deleted from all projects. The default network comes pre-configured with permissive firewall rules (allow SSH, RDP, @@ -130,7 +128,9 @@ def check_default_network_not_created( status=ComplianceStatus.FAIL, domain=CheckDomain.NETWORKING, resource_type="GCP::Compute::Network", - resource_id=default_net.get("selfLink", f"projects/{project_id}/global/networks/default"), + resource_id=default_net.get( + "selfLink", f"projects/{project_id}/global/networks/default" + ), region=region, account_id=project_id, cloud_provider=CloudProvider.GCP, @@ -145,18 +145,18 @@ def check_default_network_not_created( ] -def check_firewall_no_unrestricted_ssh( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_firewall_no_unrestricted_ssh(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 3.6] No firewall rule should allow SSH (port 22) from 0.0.0.0/0 or ::/0.""" - return _check_unrestricted_port(client, project_id, "22", "SSH", "gcp-firewall-unrestricted-ssh", "3.6") + return _check_unrestricted_port( + client, project_id, "22", "SSH", "gcp-firewall-unrestricted-ssh", "3.6" + ) -def check_firewall_no_unrestricted_rdp( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_firewall_no_unrestricted_rdp(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 3.7] No firewall rule should allow RDP (port 3389) from 0.0.0.0/0 or ::/0.""" - return _check_unrestricted_port(client, project_id, "3389", "RDP", "gcp-firewall-unrestricted-rdp", "3.7") + return _check_unrestricted_port( + client, project_id, "3389", "RDP", "gcp-firewall-unrestricted-rdp", "3.7" + ) def _check_unrestricted_port( @@ -200,10 +200,14 @@ def _check_unrestricted_port( proto = allowed.get("IPProtocol", "") ports = allowed.get("ports", []) if proto in ("all", "tcp"): - if not ports or port in ports or any( - "-" in p and int(p.split("-")[0]) <= int(port) <= int(p.split("-")[1]) - for p in ports - if "-" in p + if ( + not ports + or port in ports + or any( + "-" in p and int(p.split("-")[0]) <= int(port) <= int(p.split("-")[1]) + for p in ports + if "-" in p + ) ): offenders.append( { @@ -263,9 +267,7 @@ def _check_unrestricted_port( ] -def check_firewall_no_unrestricted_admin_ports( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_firewall_no_unrestricted_admin_ports(client: GCPClient, project_id: str) -> list[Finding]: """No firewall rule should allow common database/admin ports from 0.0.0.0/0. Beyond SSH and RDP, exposing database ports (MySQL 3306, PostgreSQL 5432, @@ -308,10 +310,15 @@ def check_firewall_no_unrestricted_admin_ports( for danger_port, service in DANGEROUS_PORTS.items(): if danger_port in ("22", "3389"): continue # Covered by dedicated checks above - if not ports or danger_port in ports or any( - "-" in p and int(p.split("-")[0]) <= int(danger_port) <= int(p.split("-")[1]) - for p in ports - if "-" in p + if ( + not ports + or danger_port in ports + or any( + "-" in p + and int(p.split("-")[0]) <= int(danger_port) <= int(p.split("-")[1]) + for p in ports + if "-" in p + ) ): offenders.append( { @@ -347,7 +354,7 @@ def check_firewall_no_unrestricted_admin_ports( title=f"{len(offenders)} firewall rule(s) expose admin/database ports to internet", description=( f"{len(offenders)} rule(s) expose admin or database ports to 0.0.0.0/0. " - "Exposed services: " + ", ".join({o['service'] for o in offenders}) + ". " + "Exposed services: " + ", ".join({o["service"] for o in offenders}) + ". " "These ports should be behind VPN or Cloud IAP, not exposed to the internet." ), severity=Severity.CRITICAL, @@ -406,7 +413,10 @@ def check_subnet_flow_logs_enabled( log_config = subnet.get("logConfig") or {} enabled = log_config.get("enable", False) subnet_name = subnet.get("name", "unknown") - resource_id = subnet.get("selfLink") or f"projects/{project_id}/regions/{region}/subnetworks/{subnet_name}" + resource_id = ( + subnet.get("selfLink") + or f"projects/{project_id}/regions/{region}/subnetworks/{subnet_name}" + ) if enabled: findings.append( @@ -493,7 +503,10 @@ def check_private_google_access_enabled( for subnet in subnets: enabled = subnet.get("privateIpGoogleAccess", False) subnet_name = subnet.get("name", "unknown") - resource_id = subnet.get("selfLink") or f"projects/{project_id}/regions/{region}/subnetworks/{subnet_name}" + resource_id = ( + subnet.get("selfLink") + or f"projects/{project_id}/regions/{region}/subnetworks/{subnet_name}" + ) if enabled: findings.append( @@ -543,9 +556,7 @@ def check_private_google_access_enabled( return findings -def check_dns_logging_enabled( - client: GCPClient, project_id: str -) -> list[Finding]: +def check_dns_logging_enabled(client: GCPClient, project_id: str) -> list[Finding]: """[CIS 3.10] Cloud DNS logging should be enabled for all managed zones. DNS query logs reveal which domains workloads resolve, enabling detection diff --git a/src/shasta/gcp/storage.py b/src/shasta/gcp/storage.py index 0eef35d..c70e45d 100644 --- a/src/shasta/gcp/storage.py +++ b/src/shasta/gcp/storage.py @@ -10,7 +10,6 @@ from typing import Any -from shasta.gcp.client import GCPClient from shasta.evidence.models import ( CheckDomain, CloudProvider, @@ -18,6 +17,7 @@ Finding, Severity, ) +from shasta.gcp.client import GCPClient IS_GLOBAL = True # GCS buckets are listed project-wide (not per-region) diff --git a/src/shasta/integrations/github.py b/src/shasta/integrations/github.py index 8e47bc8..99da23d 100644 --- a/src/shasta/integrations/github.py +++ b/src/shasta/integrations/github.py @@ -11,8 +11,7 @@ from __future__ import annotations import json -from typing import Any -from urllib import request, error +from urllib import error, request from shasta.evidence.models import CheckDomain, ComplianceStatus, Finding, Severity diff --git a/src/shasta/integrations/jira.py b/src/shasta/integrations/jira.py index 651a439..11cbf54 100644 --- a/src/shasta/integrations/jira.py +++ b/src/shasta/integrations/jira.py @@ -11,8 +11,7 @@ import json from base64 import b64encode from dataclasses import dataclass -from typing import Any -from urllib import request, error +from urllib import error, request from shasta.evidence.models import Finding @@ -53,13 +52,6 @@ def _request(self, method: str, path: str, data: dict | None = None) -> dict: def create_finding_ticket(self, finding: Finding) -> JiraTicket: """Create a Jira ticket for a compliance finding.""" - severity_priority = { - "critical": "Highest", - "high": "High", - "medium": "Medium", - "low": "Low", - "info": "Lowest", - } description = { "type": "doc", diff --git a/src/shasta/integrations/slack.py b/src/shasta/integrations/slack.py index 9f776c4..a35af69 100644 --- a/src/shasta/integrations/slack.py +++ b/src/shasta/integrations/slack.py @@ -7,8 +7,7 @@ from __future__ import annotations import json -from typing import Any -from urllib import request, error +from urllib import error, request from shasta.compliance.scorer import ComplianceScore from shasta.evidence.models import Finding, ScanResult @@ -47,7 +46,7 @@ def send_scan_summary(self, scan: ScanResult, score: ComplianceScore) -> bool: blocks = [ { "type": "header", - "text": {"type": "plain_text", "text": f"Shasta Compliance Scan Complete"}, + "text": {"type": "plain_text", "text": "Shasta Compliance Scan Complete"}, }, { "type": "section", diff --git a/src/shasta/policies/generator.py b/src/shasta/policies/generator.py index d3945d2..92aaa76 100644 --- a/src/shasta/policies/generator.py +++ b/src/shasta/policies/generator.py @@ -17,7 +17,7 @@ from datetime import datetime from pathlib import Path -from jinja2 import Environment, BaseLoader +from jinja2 import BaseLoader, Environment # --------------------------------------------------------------------------- # Policy Templates diff --git a/src/shasta/remediation/engine.py b/src/shasta/remediation/engine.py index 962a157..b6ef6e7 100644 --- a/src/shasta/remediation/engine.py +++ b/src/shasta/remediation/engine.py @@ -9,7 +9,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from shasta.evidence.models import ComplianceStatus, Finding, Severity @@ -397,7 +397,7 @@ def _tf_aws_ct_object_lock(f: Finding) -> str: @_tf("security-hub-enabled") def _tf_aws_security_hub(f: Finding) -> str: - return '''\ + return """\ resource "aws_securityhub_account" "main" { enable_default_standards = true } @@ -410,16 +410,16 @@ def _tf_aws_security_hub(f: Finding) -> str: resource "aws_securityhub_standards_subscription" "fsbp" { standards_arn = "arn:aws:securityhub:::standards/aws-foundational-security-best-practices/v/1.0.0" depends_on = [aws_securityhub_account.main] -}''' +}""" @_tf("iam-access-analyzer") def _tf_aws_access_analyzer(f: Finding) -> str: - return '''\ + return """\ resource "aws_accessanalyzer_analyzer" "default" { analyzer_name = "default" type = "ACCOUNT" # Use ORGANIZATION if AWS Organizations is in use -}''' +}""" # ----- Encryption: EFS / SNS / SQS / Secrets Manager / ACM ----- @@ -444,28 +444,28 @@ def _tf_aws_efs_encryption(f: Finding) -> str: @_tf("sns-encryption") def _tf_aws_sns_encryption(f: Finding) -> str: - return '''\ + return """\ resource "aws_sns_topic" "encrypted_topic" { name = "TOPIC_NAME" kms_master_key_id = "alias/aws/sns" # or a customer-managed key alias -}''' +}""" @_tf("sqs-encryption") def _tf_aws_sqs_encryption(f: Finding) -> str: - return '''\ + return """\ resource "aws_sqs_queue" "encrypted_queue" { name = "QUEUE_NAME" sqs_managed_sse_enabled = true # SQS-managed SSE (no KMS cost) # OR for KMS: # kms_master_key_id = "alias/aws/sqs" # kms_data_key_reuse_period_seconds = 300 -}''' +}""" @_tf("secrets-manager-rotation") def _tf_aws_sm_rotation(f: Finding) -> str: - return '''\ + return """\ resource "aws_secretsmanager_secret" "db_password" { name = "db_password" } @@ -477,12 +477,12 @@ def _tf_aws_sm_rotation(f: Finding) -> str: rotation_rules { automatically_after_days = 30 } -}''' +}""" @_tf("acm-expiring-certs") def _tf_aws_acm_renewal(f: Finding) -> str: - return '''\ + return """\ # Use DNS validation so ACM auto-renews ~60 days before expiry. # For email-validated or imported certs, switch to DNS-validated: resource "aws_acm_certificate" "main" { @@ -507,7 +507,7 @@ def _tf_aws_acm_renewal(f: Finding) -> str: records = [each.value.record] type = each.value.type ttl = 60 -}''' +}""" # ----- Networking: ELB v2 ----- @@ -515,7 +515,7 @@ def _tf_aws_acm_renewal(f: Finding) -> str: @_tf("elb-listener-tls") def _tf_aws_elb_tls(f: Finding) -> str: - return '''\ + return """\ # Use a modern TLS policy and redirect HTTP -> HTTPS resource "aws_lb_listener" "https" { load_balancer_arn = aws_lb.main.arn @@ -543,12 +543,12 @@ def _tf_aws_elb_tls(f: Finding) -> str: status_code = "HTTP_301" } } -}''' +}""" @_tf("elb-access-logs") def _tf_aws_elb_access_logs(f: Finding) -> str: - return '''\ + return """\ resource "aws_lb" "main" { name = "main" # ... existing config ... @@ -558,18 +558,18 @@ def _tf_aws_elb_access_logs(f: Finding) -> str: prefix = "alb-logs" enabled = true } -}''' +}""" @_tf("elb-drop-invalid-headers") def _tf_aws_elb_drop_headers(f: Finding) -> str: - return '''\ + return """\ resource "aws_lb" "main" { name = "main" # ... existing config ... drop_invalid_header_fields = true # CIS AWS -}''' +}""" # ----- Stage 2: Databases ----- @@ -629,7 +629,7 @@ def _tf_aws_rds_minor(f: Finding) -> str: @_tf("dynamodb-pitr") def _tf_aws_ddb_pitr(f: Finding) -> str: - return '''\ + return """\ resource "aws_dynamodb_table" "main" { name = "TABLE_NAME" # ... existing config ... @@ -637,12 +637,12 @@ def _tf_aws_ddb_pitr(f: Finding) -> str: point_in_time_recovery { enabled = true } -}''' +}""" @_tf("dynamodb-kms") def _tf_aws_ddb_kms(f: Finding) -> str: - return '''\ + return """\ resource "aws_dynamodb_table" "main" { name = "TABLE_NAME" # ... existing config ... @@ -656,7 +656,7 @@ def _tf_aws_ddb_kms(f: Finding) -> str: resource "aws_kms_key" "dynamodb" { description = "DynamoDB CMK" enable_key_rotation = true -}''' +}""" # ----- Stage 2: Serverless ----- @@ -665,19 +665,21 @@ def _tf_aws_ddb_kms(f: Finding) -> str: @_tf("lambda-runtime-eol") def _tf_aws_lambda_runtime(f: Finding) -> str: deprecated = f.details.get("deprecated", []) - examples = ", ".join(d.get("name", "") if isinstance(d, dict) else str(d) for d in deprecated[:3]) - return f'''\ + examples = ", ".join( + d.get("name", "") if isinstance(d, dict) else str(d) for d in deprecated[:3] + ) + return f"""\ # Bump deprecated Lambda runtimes ({examples}) to a current version. resource "aws_lambda_function" "example" {{ function_name = "FUNCTION_NAME" runtime = "python3.12" # or nodejs20.x / java21 / dotnet8 # ... existing config ... -}}''' +}}""" @_tf("lambda-env-kms") def _tf_aws_lambda_env_kms(f: Finding) -> str: - return '''\ + return """\ resource "aws_kms_key" "lambda_env" { description = "Lambda environment variable encryption" enable_key_rotation = true @@ -687,12 +689,12 @@ def _tf_aws_lambda_env_kms(f: Finding) -> str: function_name = "FUNCTION_NAME" kms_key_arn = aws_kms_key.lambda_env.arn # ... existing config ... -}''' +}""" @_tf("lambda-dlq") def _tf_aws_lambda_dlq(f: Finding) -> str: - return '''\ + return """\ resource "aws_sqs_queue" "lambda_dlq" { name = "lambda-dlq" message_retention_seconds = 1209600 # 14 days @@ -705,12 +707,12 @@ def _tf_aws_lambda_dlq(f: Finding) -> str: dead_letter_config { target_arn = aws_sqs_queue.lambda_dlq.arn } -}''' +}""" @_tf("apigw-logging") def _tf_aws_apigw_logging(f: Finding) -> str: - return '''\ + return """\ resource "aws_api_gateway_method_settings" "all" { rest_api_id = aws_api_gateway_rest_api.main.id stage_name = aws_api_gateway_stage.prod.stage_name @@ -720,12 +722,12 @@ def _tf_aws_apigw_logging(f: Finding) -> str: metrics_enabled = true logging_level = "INFO" } -}''' +}""" @_tf("apigw-waf") def _tf_aws_apigw_waf(f: Finding) -> str: - return '''\ + return """\ resource "aws_wafv2_web_acl" "apigw" { name = "apigw-waf" scope = "REGIONAL" @@ -758,12 +760,12 @@ def _tf_aws_apigw_waf(f: Finding) -> str: resource "aws_wafv2_web_acl_association" "apigw" { resource_arn = aws_api_gateway_stage.prod.arn web_acl_arn = aws_wafv2_web_acl.apigw.arn -}''' +}""" @_tf("sfn-logging") def _tf_aws_sfn_logging(f: Finding) -> str: - return '''\ + return """\ resource "aws_cloudwatch_log_group" "sfn" { name = "/aws/states/STATE_MACHINE_NAME" retention_in_days = 90 @@ -778,7 +780,7 @@ def _tf_aws_sfn_logging(f: Finding) -> str: include_execution_data = true level = "ALL" } -}''' +}""" # ----- Stage 2: Backup ----- @@ -808,7 +810,7 @@ def _tf_aws_backup_vault_lock(f: Finding) -> str: @_tf("aws-backup-plans") def _tf_aws_backup_plans(f: Finding) -> str: - return '''\ + return """\ resource "aws_backup_plan" "daily_35day" { name = "daily-35day" @@ -833,7 +835,7 @@ def _tf_aws_backup_plans(f: Finding) -> str: key = "backup" value = "true" } -}''' +}""" # ----- Stage 3: Cross-cutting ----- @@ -841,7 +843,7 @@ def _tf_aws_backup_plans(f: Finding) -> str: @_tf("aws-vpc-endpoints") def _tf_aws_vpc_endpoints(f: Finding) -> str: - return '''\ + return """\ # Gateway endpoints (free) resource "aws_vpc_endpoint" "s3" { vpc_id = aws_vpc.main.id @@ -873,12 +875,12 @@ def _tf_aws_vpc_endpoints(f: Finding) -> str: subnet_ids = aws_subnet.private[*].id security_group_ids = [aws_security_group.endpoints.id] private_dns_enabled = true -}''' +}""" @_tf("cwl-kms-encryption") def _tf_aws_cwl_kms(f: Finding) -> str: - return '''\ + return """\ resource "aws_kms_key" "logs" { description = "CloudWatch Logs encryption" enable_key_rotation = true @@ -900,12 +902,12 @@ def _tf_aws_cwl_kms(f: Finding) -> str: name = "/app/main" retention_in_days = 90 kms_key_id = aws_kms_key.logs.arn -}''' +}""" @_tf("cwl-retention") def _tf_aws_cwl_retention(f: Finding) -> str: - return '''\ + return """\ # Apply a retention policy to existing log groups via for_each data "aws_cloudwatch_log_groups" "all" {} @@ -913,12 +915,12 @@ def _tf_aws_cwl_retention(f: Finding) -> str: for_each = toset(data.aws_cloudwatch_log_groups.all.log_group_names) name = each.value retention_in_days = 90 # or 180/365 for compliance-critical -}''' +}""" @_tf("aws-org-scps") def _tf_aws_scps(f: Finding) -> str: - return '''\ + return """\ # Deny CloudTrail disable / delete across all member accounts resource "aws_organizations_policy" "deny_cloudtrail_disable" { name = "deny-cloudtrail-disable" @@ -943,12 +945,12 @@ def _tf_aws_scps(f: Finding) -> str: resource "aws_organizations_policy_attachment" "deny_ct" { policy_id = aws_organizations_policy.deny_cloudtrail_disable.id target_id = "ou-XXXX-XXXXXXXX" # OU or account ID -}''' +}""" @_tf("aws-tag-policy") def _tf_aws_tag_policy(f: Finding) -> str: - return '''\ + return """\ resource "aws_organizations_policy" "require_owner_tag" { name = "require-owner-tag" type = "TAG_POLICY" @@ -966,7 +968,7 @@ def _tf_aws_tag_policy(f: Finding) -> str: } } }) -}''' +}""" # --------------------------------------------------------------------------- @@ -976,7 +978,7 @@ def _tf_aws_tag_policy(f: Finding) -> str: @_tf("ec2-imdsv2-enforced") def _tf_aws_imdsv2(f: Finding) -> str: - return '''\ + return """\ # Enforce IMDSv2 on every existing instance resource "aws_ec2_instance_metadata_defaults" "account_default" { http_tokens = "required" @@ -995,12 +997,12 @@ def _tf_aws_imdsv2(f: Finding) -> str: # To remediate existing instances via CLI: # aws ec2 modify-instance-metadata-options --instance-id i-xxx \\ -# --http-tokens required --http-endpoint enabled --http-put-response-hop-limit 1''' +# --http-tokens required --http-endpoint enabled --http-put-response-hop-limit 1""" @_tf("ec2-instance-profile") def _tf_aws_ec2_instance_profile(f: Finding) -> str: - return '''\ + return """\ # Minimal IAM role for an EC2 instance with no AWS access (extend as needed) resource "aws_iam_role" "ec2_baseline" { name = "ec2-baseline" @@ -1027,7 +1029,7 @@ def _tf_aws_ec2_instance_profile(f: Finding) -> str: # Then attach to existing instances: # aws ec2 associate-iam-instance-profile --instance-id i-xxx \\ -# --iam-instance-profile Name=ec2-baseline''' +# --iam-instance-profile Name=ec2-baseline""" @_tf("eks-private-endpoint") @@ -1098,7 +1100,7 @@ def _tf_aws_eks_secrets(f: Finding) -> str: @_tf("ecs-task-privileged") def _tf_aws_ecs_no_privileged(f: Finding) -> str: - return '''\ + return """\ resource "aws_ecs_task_definition" "app" { family = "app" # ... existing config ... @@ -1111,12 +1113,12 @@ def _tf_aws_ecs_no_privileged(f: Finding) -> str: readonlyRootFilesystem = true # ... rest of definition ... }]) -}''' +}""" @_tf("ecs-task-root-user") def _tf_aws_ecs_no_root(f: Finding) -> str: - return '''\ + return """\ # In the Dockerfile of the container image: # FROM python:3.12-slim # RUN useradd -u 1000 -m app @@ -1133,12 +1135,12 @@ def _tf_aws_ecs_no_root(f: Finding) -> str: user = "1000" # Non-root uid matching the Dockerfile # ... rest of definition ... }]) -}''' +}""" @_tf("kms-key-rotation") def _tf_aws_kms_rotation(f: Finding) -> str: - return '''\ + return """\ # Enable rotation on every existing customer-managed CMK: # # for key_id in $(aws kms list-keys --query "Keys[].KeyId" --output text); do @@ -1154,12 +1156,12 @@ def _tf_aws_kms_rotation(f: Finding) -> str: description = "..." enable_key_rotation = true deletion_window_in_days = 30 -}''' +}""" @_tf("kms-key-policy-wildcards") def _tf_aws_kms_policy(f: Finding) -> str: - return '''\ + return """\ # Replace wildcard key policies with scoped principals resource "aws_kms_key" "example" { description = "..." @@ -1186,14 +1188,14 @@ def _tf_aws_kms_policy(f: Finding) -> str: } ] }) -}''' +}""" @_tf("iam-policy-wildcards") def _tf_aws_iam_no_wildcards(f: Finding) -> str: policies = f.details.get("wildcard_policies", []) names = ", ".join(p.get("policy_name", "") for p in policies[:3]) or "" - return f'''\ + return f"""\ # Replace wildcard policies ({names}) with scoped equivalents. # Use IAM Access Analyzer policy generation to derive a policy from # CloudTrail data: @@ -1218,12 +1220,12 @@ def _tf_aws_iam_no_wildcards(f: Finding) -> str: Resource = "arn:aws:s3:::specific-bucket/specific-prefix/*" }}] }}) -}}''' +}}""" @_tf("iam-role-trust-external") def _tf_aws_iam_external_id(f: Finding) -> str: - return '''\ + return """\ # Add an ExternalId condition to every cross-account role trust policy resource "aws_iam_role" "third_party_integration" { name = "third-party-integration" @@ -1242,12 +1244,12 @@ def _tf_aws_iam_external_id(f: Finding) -> str: } }] }) -}''' +}""" @_tf("cloudwatch-alarms-cis-4") def _tf_aws_cwl_cis_4(f: Finding) -> str: - return '''\ + return """\ # CloudTrail metric filters + alarms for CIS AWS 4.1-4.15 # This is a representative sample — the full set is 15 filter+alarm pairs. # Run them in the home region of the multi-region trail. @@ -1286,12 +1288,12 @@ def _tf_aws_cwl_cis_4(f: Finding) -> str: } # Repeat the metric_filter + metric_alarm pair for each of CIS 4.1-4.15. -# CIS Foundations Benchmark v3.0 spec lists the exact filter pattern for each.''' +# CIS Foundations Benchmark v3.0 spec lists the exact filter pattern for each.""" @_tf("aws-config-conformance-packs") def _tf_aws_conformance_pack(f: Finding) -> str: - return '''\ + return """\ # Deploy the CIS AWS Foundations Benchmark v3.0 conformance pack resource "aws_config_conformance_pack" "cis_aws_v3" { name = "cis-aws-foundations-v3" @@ -1301,7 +1303,7 @@ def _tf_aws_conformance_pack(f: Finding) -> str: # Or use AWS-managed conformance pack templates directly via the console: # Config > Conformance packs > Deploy conformance pack > Use sample template -# > select "Operational Best Practices for CIS AWS v3.0".''' +# > select "Operational Best Practices for CIS AWS v3.0".""" # --------------------------------------------------------------------------- @@ -1312,7 +1314,7 @@ def _tf_aws_conformance_pack(f: Finding) -> str: @_tf("cloudfront-https-only") def _tf_aws_cf_https(f: Finding) -> str: - return '''\ + return """\ resource "aws_cloudfront_distribution" "main" { # ... existing config ... @@ -1327,12 +1329,12 @@ def _tf_aws_cf_https(f: Finding) -> str: viewer_protocol_policy = "redirect-to-https" # ... rest of behavior ... } -}''' +}""" @_tf("cloudfront-min-tls") def _tf_aws_cf_tls(f: Finding) -> str: - return '''\ + return """\ resource "aws_cloudfront_distribution" "main" { # ... existing config ... @@ -1348,12 +1350,12 @@ def _tf_aws_cf_tls(f: Finding) -> str: provider = aws.us_east_1 domain_name = "example.com" validation_method = "DNS" -}''' +}""" @_tf("cloudfront-waf") def _tf_aws_cf_waf(f: Finding) -> str: - return '''\ + return """\ # Web ACL must be in us-east-1 for CloudFront resource "aws_wafv2_web_acl" "cloudfront" { provider = aws.us_east_1 @@ -1388,12 +1390,12 @@ def _tf_aws_cf_waf(f: Finding) -> str: resource "aws_cloudfront_distribution" "main" { web_acl_id = aws_wafv2_web_acl.cloudfront.arn # ... existing config ... -}''' +}""" @_tf("cloudfront-oac") def _tf_aws_cf_oac(f: Finding) -> str: - return '''\ + return """\ resource "aws_cloudfront_origin_access_control" "s3" { name = "s3-oac" description = "OAC for S3 origin" @@ -1428,12 +1430,12 @@ def _tf_aws_cf_oac(f: Finding) -> str: } }] }) -}''' +}""" @_tf("redshift-encryption") def _tf_aws_redshift_encrypt(f: Finding) -> str: - return '''\ + return """\ resource "aws_kms_key" "redshift" { description = "Redshift cluster encryption" enable_key_rotation = true @@ -1445,12 +1447,12 @@ def _tf_aws_redshift_encrypt(f: Finding) -> str: encrypted = true kms_key_id = aws_kms_key.redshift.arn # ... existing config ... -}''' +}""" @_tf("redshift-public-access") def _tf_aws_redshift_private(f: Finding) -> str: - return '''\ + return """\ resource "aws_redshift_cluster" "main" { cluster_identifier = "main" publicly_accessible = false @@ -1458,12 +1460,12 @@ def _tf_aws_redshift_private(f: Finding) -> str: cluster_subnet_group_name = aws_redshift_subnet_group.private.name vpc_security_group_ids = [aws_security_group.redshift.id] # ... existing config ... -}''' +}""" @_tf("redshift-audit-logging") def _tf_aws_redshift_logging(f: Finding) -> str: - return '''\ + return """\ resource "aws_s3_bucket" "redshift_logs" { bucket = "company-redshift-audit-logs" } @@ -1477,12 +1479,12 @@ def _tf_aws_redshift_logging(f: Finding) -> str: bucket_name = aws_s3_bucket.redshift_logs.id s3_key_prefix = "redshift-audit/" } -}''' +}""" @_tf("redshift-require-ssl") def _tf_aws_redshift_ssl(f: Finding) -> str: - return '''\ + return """\ resource "aws_redshift_parameter_group" "secure" { family = "redshift-1.0" name = "redshift-secure" @@ -1497,12 +1499,12 @@ def _tf_aws_redshift_ssl(f: Finding) -> str: cluster_identifier = "main" cluster_parameter_group_name = aws_redshift_parameter_group.secure.name # ... existing config ... -}''' +}""" @_tf("elasticache-transit-encryption") def _tf_aws_ec_transit(f: Finding) -> str: - return '''\ + return """\ resource "aws_elasticache_replication_group" "main" { replication_group_id = "main" description = "Redis with TLS" @@ -1510,12 +1512,12 @@ def _tf_aws_ec_transit(f: Finding) -> str: at_rest_encryption_enabled = true auth_token = "REDIS_AUTH_TOKEN" # ... existing config ... -}''' +}""" @_tf("elasticache-at-rest-encryption") def _tf_aws_ec_at_rest(f: Finding) -> str: - return '''\ + return """\ resource "aws_kms_key" "elasticache" { description = "ElastiCache encryption" enable_key_rotation = true @@ -1527,12 +1529,12 @@ def _tf_aws_ec_at_rest(f: Finding) -> str: at_rest_encryption_enabled = true kms_key_id = aws_kms_key.elasticache.arn # ... existing config ... -}''' +}""" @_tf("elasticache-auth-token") def _tf_aws_ec_auth(f: Finding) -> str: - return '''\ + return """\ resource "aws_elasticache_replication_group" "main" { replication_group_id = "main" description = "Redis with AUTH" @@ -1544,12 +1546,12 @@ def _tf_aws_ec_auth(f: Finding) -> str: # Store the auth token in Secrets Manager data "aws_secretsmanager_secret_version" "redis_auth" { secret_id = "redis-auth-token" -}''' +}""" @_tf("neptune-encryption") def _tf_aws_neptune_encrypt(f: Finding) -> str: - return '''\ + return """\ resource "aws_kms_key" "neptune" { description = "Neptune cluster encryption" enable_key_rotation = true @@ -1560,7 +1562,7 @@ def _tf_aws_neptune_encrypt(f: Finding) -> str: storage_encrypted = true kms_key_arn = aws_kms_key.neptune.arn # ... existing config ... -}''' +}""" @_tf("rds-force-ssl") @@ -1592,7 +1594,7 @@ def _tf_aws_rds_force_ssl(f: Finding) -> str: @_tf("rds-postgres-log-settings") def _tf_aws_rds_pg_logs(f: Finding) -> str: - return '''\ + return """\ resource "aws_db_parameter_group" "postgres_audit" { name = "postgres-audit" family = "postgres15" # adjust to your version @@ -1619,12 +1621,12 @@ def _tf_aws_rds_pg_logs(f: Finding) -> str: parameter_group_name = aws_db_parameter_group.postgres_audit.name enabled_cloudwatch_logs_exports = ["postgresql"] # ... existing config ... -}''' +}""" @_tf("rds-min-tls") def _tf_aws_rds_min_tls(f: Finding) -> str: - return '''\ + return """\ # SQL Server RDS only resource "aws_db_parameter_group" "sqlserver_tls" { name = "sqlserver-tls" @@ -1635,21 +1637,21 @@ def _tf_aws_rds_min_tls(f: Finding) -> str: value = "1.2" apply_method = "pending-reboot" } -}''' +}""" @_tf("lambda-function-url-auth") def _tf_aws_lambda_url_auth(f: Finding) -> str: - return '''\ + return """\ resource "aws_lambda_function_url" "secured" { function_name = aws_lambda_function.example.function_name authorization_type = "AWS_IAM" # never NONE for production endpoints -}''' +}""" @_tf("lambda-layer-origin") def _tf_aws_lambda_layer(f: Finding) -> str: - return '''\ + return """\ # Re-publish foreign-account layers in your own account so you control updates resource "aws_lambda_layer_version" "vendored" { layer_name = "vendored-third-party" @@ -1662,12 +1664,12 @@ def _tf_aws_lambda_layer(f: Finding) -> str: function_name = "example" layers = [aws_lambda_layer_version.vendored.arn] # ... existing config ... -}''' +}""" @_tf("apigw-client-cert") def _tf_aws_apigw_client_cert(f: Finding) -> str: - return '''\ + return """\ resource "aws_api_gateway_client_certificate" "main" { description = "Client cert for backend integration auth" } @@ -1677,24 +1679,24 @@ def _tf_aws_apigw_client_cert(f: Finding) -> str: stage_name = "prod" client_certificate_id = aws_api_gateway_client_certificate.main.id # ... existing config ... -}''' +}""" @_tf("apigw-authorizer") def _tf_aws_apigw_authorizer(f: Finding) -> str: - return '''\ + return """\ # IAM auth on every method (replace AWS_IAM with COGNITO_USER_POOLS or CUSTOM as needed) resource "aws_api_gateway_method" "secured_get" { rest_api_id = aws_api_gateway_rest_api.main.id resource_id = aws_api_gateway_resource.example.id http_method = "GET" authorization = "AWS_IAM" # or COGNITO_USER_POOLS / CUSTOM -}''' +}""" @_tf("apigw-throttling") def _tf_aws_apigw_throttle(f: Finding) -> str: - return '''\ + return """\ resource "aws_api_gateway_method_settings" "throttle" { rest_api_id = aws_api_gateway_rest_api.main.id stage_name = aws_api_gateway_stage.prod.stage_name @@ -1704,12 +1706,12 @@ def _tf_aws_apigw_throttle(f: Finding) -> str: throttling_burst_limit = 200 throttling_rate_limit = 100 } -}''' +}""" @_tf("apigw-request-validation") def _tf_aws_apigw_validate(f: Finding) -> str: - return '''\ + return """\ resource "aws_api_gateway_request_validator" "body_and_params" { name = "body-and-params" rest_api_id = aws_api_gateway_rest_api.main.id @@ -1723,24 +1725,24 @@ def _tf_aws_apigw_validate(f: Finding) -> str: http_method = "POST" authorization = "AWS_IAM" request_validator_id = aws_api_gateway_request_validator.body_and_params.id -}''' +}""" @_tf("s3-object-ownership") def _tf_aws_s3_oo(f: Finding) -> str: - return '''\ + return """\ resource "aws_s3_bucket_ownership_controls" "enforce" { bucket = aws_s3_bucket.main.id rule { object_ownership = "BucketOwnerEnforced" # disables ACLs entirely } -}''' +}""" @_tf("s3-access-logging") def _tf_aws_s3_logging(f: Finding) -> str: - return '''\ + return """\ # A dedicated log destination bucket with Object Lock + lifecycle resource "aws_s3_bucket" "logs" { bucket = "company-s3-access-logs" @@ -1751,12 +1753,12 @@ def _tf_aws_s3_logging(f: Finding) -> str: bucket = aws_s3_bucket.main.id target_bucket = aws_s3_bucket.logs.id target_prefix = "main/" -}''' +}""" @_tf("s3-kms-cmk") def _tf_aws_s3_kms_cmk(f: Finding) -> str: - return '''\ + return """\ resource "aws_kms_key" "s3" { description = "S3 bucket encryption" enable_key_rotation = true @@ -1772,12 +1774,12 @@ def _tf_aws_s3_kms_cmk(f: Finding) -> str: } bucket_key_enabled = true # Reduces KMS API costs } -}''' +}""" @_tf("aws-backup-cross-region-copy") def _tf_aws_backup_crr(f: Finding) -> str: - return '''\ + return """\ resource "aws_backup_vault" "secondary" { provider = aws.us_west_2 # destination region name = "secondary" @@ -1804,12 +1806,12 @@ def _tf_aws_backup_crr(f: Finding) -> str: } } } -}''' +}""" @_tf("aws-backup-vault-access-policy") def _tf_aws_backup_access_policy(f: Finding) -> str: - return '''\ + return """\ resource "aws_backup_vault_policy" "deny_destructive" { backup_vault_name = aws_backup_vault.primary.name @@ -1830,7 +1832,7 @@ def _tf_aws_backup_access_policy(f: Finding) -> str: Resource = "*" }] }) -}''' +}""" # --------------------------------------------------------------------------- @@ -2278,7 +2280,7 @@ def _tf_az_rsv_redundancy(f: Finding) -> str: @_tf("azure-vnet-flow-logs-modern") def _tf_az_vnet_flow_logs(f: Finding) -> str: - return '''\ + return """\ # VNet flow logs — successor to NSG flow logs (CIS 6.4) resource "azurerm_network_watcher_flow_log" "vnet_flow" { network_watcher_name = "NetworkWatcher_LOCATION" @@ -2301,7 +2303,7 @@ def _tf_az_vnet_flow_logs(f: Finding) -> str: workspace_resource_id = azurerm_log_analytics_workspace.main.id interval_in_minutes = 10 } -}''' +}""" @_tf("azure-network-watcher-coverage") @@ -2339,7 +2341,7 @@ def _tf_az_defender_per_plan(f: Finding) -> str: @_tf("azure-activity-log-alerts") def _tf_az_activity_alerts(f: Finding) -> str: - return '''\ + return """\ # CIS 5.2.x — alert on critical control-plane changes locals { critical_operations = [ @@ -2381,7 +2383,7 @@ def _tf_az_activity_alerts(f: Finding) -> str: action { action_group_id = azurerm_monitor_action_group.secops.id } -}''' +}""" # ----- Governance ----- @@ -2401,7 +2403,7 @@ def _tf_az_resource_locks(f: Finding) -> str: @_tf("azure-required-tags") def _tf_az_required_tags(f: Finding) -> str: - return '''\ + return """\ # Built-in policy: 'Require a tag and its value on resource groups' data "azurerm_policy_definition" "require_tag" { display_name = "Require a tag and its value on resource groups" @@ -2427,12 +2429,12 @@ def _tf_az_required_tags(f: Finding) -> str: tagName = { value = "environment" } tagValue = { value = "production" } }) -}''' +}""" @_tf("azure-security-initiative") def _tf_az_security_initiative(f: Finding) -> str: - return '''\ + return """\ # Assign the Microsoft Cloud Security Benchmark initiative (CIS 2.x) data "azurerm_policy_set_definition" "mcsb" { display_name = "Microsoft cloud security benchmark" @@ -2444,7 +2446,7 @@ def _tf_az_security_initiative(f: Finding) -> str: policy_definition_id = data.azurerm_policy_set_definition.mcsb.id display_name = "Microsoft Cloud Security Benchmark" description = "Continuous compliance against the MCSB." -}''' +}""" # --------------------------------------------------------------------------- @@ -2490,7 +2492,11 @@ def _tf_gcp_primitive_roles(f: Finding) -> str: def _tf_gcp_sa_not_admin(f: Finding) -> str: project = f.account_id or "PROJECT_ID" offenders = f.details.get("offenders", []) - sa_email = offenders[0]["member"].replace("serviceAccount:", "") if offenders else "SA_EMAIL@PROJECT.iam.gserviceaccount.com" + sa_email = ( + offenders[0]["member"].replace("serviceAccount:", "") + if offenders + else "SA_EMAIL@PROJECT.iam.gserviceaccount.com" + ) role = offenders[0].get("role", "roles/editor") if offenders else "roles/editor" return f'''\ # Remove admin role from service account and replace with scoped role. @@ -2631,7 +2637,7 @@ def _tf_gcp_flow_logs(f: Finding) -> str: }} # For all subnets without flow logs, use gcloud: -# for subnet in {' '.join(missing[:5])}; do +# for subnet in {" ".join(missing[:5])}; do # gcloud compute networks subnets update $subnet \\ # --region={region} --enable-flow-logs \\ # --logging-aggregation-interval=interval-5-sec \\ @@ -2644,8 +2650,16 @@ def _tf_gcp_kms_rotation(f: Finding) -> str: project = f.account_id or "PROJECT_ID" keys_info = f.details.get("keys_with_issues", []) key_name = keys_info[0]["key"].split("/")[-1] if keys_info else "my-key" - keyring = keys_info[0]["key"].split("/keyRings/")[1].split("/")[0] if keys_info and "/keyRings/" in keys_info[0].get("key", "") else "my-keyring" - location = keys_info[0]["key"].split("/locations/")[1].split("/")[0] if keys_info and "/locations/" in keys_info[0].get("key", "") else "global" + keyring = ( + keys_info[0]["key"].split("/keyRings/")[1].split("/")[0] + if keys_info and "/keyRings/" in keys_info[0].get("key", "") + else "my-keyring" + ) + location = ( + keys_info[0]["key"].split("/locations/")[1].split("/")[0] + if keys_info and "/locations/" in keys_info[0].get("key", "") + else "global" + ) return f'''\ # Configure KMS key rotation period to ≤90 days (7,776,000 seconds). @@ -3114,7 +3128,9 @@ def _tf_gcp_cloudrun_binauthz(f: Finding) -> str: def _tf_gcp_cloudrun_secrets(f: Finding) -> str: project = f.account_id or "PROJECT_ID" region = f.region or "us-central1" - suspicious = f.details.get("suspicious_env_vars", [{"service": "my-service", "env_var": "API_KEY"}]) + suspicious = f.details.get( + "suspicious_env_vars", [{"service": "my-service", "env_var": "API_KEY"}] + ) svc_name = suspicious[0].get("service", "my-service") if suspicious else "my-service" env_var = suspicious[0].get("env_var", "API_KEY") if suspicious else "API_KEY" secret_id = env_var.lower().replace("_", "-") @@ -3704,7 +3720,7 @@ def _tf_gcp_cloudrun_secrets(f: Finding) -> str: "eks-audit-logging": { "explanation": "Without EKS audit + authenticator logs, you cannot reconstruct who issued kubectl commands during a security incident. EKS lets you enable api / audit / authenticator / controllerManager / scheduler — all five are recommended for SOC 2.", "steps": [ - "aws eks update-cluster-config --name --logging '{\"clusterLogging\":[{\"types\":[\"api\",\"audit\",\"authenticator\",\"controllerManager\",\"scheduler\"],\"enabled\":true}]}'", + 'aws eks update-cluster-config --name --logging \'{"clusterLogging":[{"types":["api","audit","authenticator","controllerManager","scheduler"],"enabled":true}]}\'', "Set retention on the /aws/eks//cluster log group to 90+ days", ], "effort": "quick", diff --git a/src/shasta/reports/generator.py b/src/shasta/reports/generator.py index 4a25247..34c540d 100644 --- a/src/shasta/reports/generator.py +++ b/src/shasta/reports/generator.py @@ -6,12 +6,11 @@ from __future__ import annotations -from datetime import datetime, timezone +import html as html_mod +from datetime import UTC, datetime from pathlib import Path -from jinja2 import Environment, BaseLoader - -import html as html_mod +from jinja2 import BaseLoader, Environment from shasta.compliance.mapper import get_control_summary from shasta.compliance.scorer import calculate_score @@ -470,7 +469,7 @@ def _build_context(scan: ScanResult) -> dict: labels = _provider_labels(scan.cloud_provider) return { - "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), + "generated_at": datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC"), "account_id": scan.account_id, "account_label": labels["account_label"], "region": scan.region, diff --git a/src/shasta/reports/hipaa_report.py b/src/shasta/reports/hipaa_report.py index 5b38a4c..8380e18 100644 --- a/src/shasta/reports/hipaa_report.py +++ b/src/shasta/reports/hipaa_report.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from shasta.compliance.hipaa_mapper import get_hipaa_control_summary @@ -39,7 +39,7 @@ def save_hipaa_report(scan: ScanResult, output_path: Path | str = "data/reports" lines = [ "# HIPAA Security Rule Gap Analysis Report", "", - f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", + f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M UTC')}", f"**{labels['account_label']}:** {scan.account_id}", f"**Region:** {scan.region}", "", diff --git a/src/shasta/reports/iso27001_report.py b/src/shasta/reports/iso27001_report.py index ac83d26..f334772 100644 --- a/src/shasta/reports/iso27001_report.py +++ b/src/shasta/reports/iso27001_report.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from shasta.compliance.iso27001_mapper import get_iso27001_control_summary @@ -36,7 +36,7 @@ def save_iso27001_markdown_report( lines = [ "# ISO 27001:2022 Gap Analysis Report", "", - f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", + f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M UTC')}", f"**{labels['account_label']}:** {scan.account_id}", f"**Region:** {scan.region}", "", diff --git a/src/shasta/reports/multi_framework_html.py b/src/shasta/reports/multi_framework_html.py index a82e4f7..3504c0d 100644 --- a/src/shasta/reports/multi_framework_html.py +++ b/src/shasta/reports/multi_framework_html.py @@ -7,7 +7,7 @@ from __future__ import annotations import html -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from shasta.compliance.hipaa_mapper import get_hipaa_control_summary @@ -18,7 +18,6 @@ from shasta.evidence.models import Finding, ScanResult from shasta.reports.generator import _render_details_html - # --------------------------------------------------------------------------- # Shared CSS (embedded in every report) # --------------------------------------------------------------------------- @@ -112,7 +111,7 @@ def _wrap(title: str, body: str) -> str: {body} - + """ @@ -138,7 +137,7 @@ def save_iso27001_html_report(scan: ScanResult, output_path: Path | str = "data/ score = calculate_iso27001_score(scan.findings) controls = get_iso27001_control_summary(scan.findings) - generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + generated = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") rows = [] for ctrl_id, data in sorted(controls.items()): @@ -245,7 +244,7 @@ def save_hipaa_html_report(scan: ScanResult, output_path: Path | str = "data/rep score = calculate_hipaa_score(scan.findings) controls = get_hipaa_control_summary(scan.findings) - generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + generated = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") def _safeguard_table(safeguard: str) -> str: rows = [] @@ -370,7 +369,7 @@ def save_whitney_html_report( filepath = output_dir / f"whitney-ai-governance-{account_id}-{timestamp}.html" score = calculate_ai_governance_score(ai_findings) - generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + generated = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") def _findings_rows(findings: list[Finding], statuses: tuple[str, ...]) -> str: rows = [] @@ -548,7 +547,7 @@ def save_consolidated_html_report( iso = calculate_iso27001_score(scan.findings) hipaa = calculate_hipaa_score(scan.findings) hipaa_ctrls = get_hipaa_control_summary(scan.findings) - generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + generated = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") whitney_html = "" if ai_findings: diff --git a/src/shasta/reports/summary.py b/src/shasta/reports/summary.py index 8cd5542..2d5a69f 100644 --- a/src/shasta/reports/summary.py +++ b/src/shasta/reports/summary.py @@ -8,7 +8,6 @@ from __future__ import annotations from collections import defaultdict -from typing import Any from shasta.evidence.models import Finding, ScanResult diff --git a/src/shasta/sbom/discovery.py b/src/shasta/sbom/discovery.py index 99e0571..fbc184b 100644 --- a/src/shasta/sbom/discovery.py +++ b/src/shasta/sbom/discovery.py @@ -13,13 +13,10 @@ from __future__ import annotations import json -import zipfile -import io import re from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any from botocore.exceptions import ClientError @@ -123,7 +120,7 @@ def discover_sbom(client: AWSClient) -> SBOMReport: report = SBOMReport( account_id=account_id, - generated_at=datetime.now(timezone.utc).isoformat(), + generated_at=datetime.now(UTC).isoformat(), ) # Discover from Lambda @@ -195,7 +192,7 @@ def _discover_lambda_dependencies(client: AWSClient) -> list[Dependency]: # Try to get function code metadata for package detection try: config = lam.get_function(FunctionName=func_name) - code_size = config.get("Configuration", {}).get("CodeSize", 0) + config.get("Configuration", {}).get("CodeSize", 0) # If code is small enough, we could download and scan # For now, record the function's environment vars for framework detection env_vars = ( diff --git a/src/shasta/sbom/vuln_scanner.py b/src/shasta/sbom/vuln_scanner.py index 3766fe4..8af8227 100644 --- a/src/shasta/sbom/vuln_scanner.py +++ b/src/shasta/sbom/vuln_scanner.py @@ -10,10 +10,9 @@ import json from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any -from urllib import request, error +from urllib import error, request from shasta.sbom.discovery import Dependency, SBOMReport @@ -65,7 +64,7 @@ class VulnScanResult: def scan_sbom_vulnerabilities(sbom: SBOMReport) -> VulnScanResult: """Scan all SBOM dependencies against vulnerability databases.""" result = VulnScanResult( - scanned_at=datetime.now(timezone.utc).isoformat(), + scanned_at=datetime.now(UTC).isoformat(), total_dependencies=sbom.total_dependencies, ) @@ -171,7 +170,7 @@ def _query_osv_batch(dependencies: list[Dependency]) -> list[VulnerabilityMatch] ) ) - except (error.URLError, json.JSONDecodeError) as e: + except (error.URLError, json.JSONDecodeError): # OSV is optional — don't fail the whole scan pass diff --git a/src/shasta/threat_intel/advisory.py b/src/shasta/threat_intel/advisory.py index f739faa..860568d 100644 --- a/src/shasta/threat_intel/advisory.py +++ b/src/shasta/threat_intel/advisory.py @@ -13,10 +13,9 @@ import json from dataclasses import dataclass, field -from datetime import datetime, timezone, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Any -from urllib import request, error +from urllib import error, request from shasta.sbom.discovery import SBOMReport @@ -56,7 +55,7 @@ def generate_daily_advisory( lookback_days: int = 1, ) -> DailyAdvisoryReport: """Generate a personalized daily threat advisory.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) start_date = now - timedelta(days=lookback_days) # Build tech stack profile from SBOM @@ -123,7 +122,7 @@ def _query_nvd(tech_stack: dict, start_date: datetime) -> list[ThreatAdvisory]: for term in search_terms: try: start_str = start_date.strftime("%Y-%m-%dT00:00:00.000") - end_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT23:59:59.999") + end_str = datetime.now(UTC).strftime("%Y-%m-%dT23:59:59.999") url = ( f"https://services.nvd.nist.gov/rest/json/cves/2.0" @@ -176,7 +175,7 @@ def _query_nvd(tech_stack: dict, start_date: datetime) -> list[ThreatAdvisory]: published=published, description=desc[:500], affected_component=f"{term} {version}", - affected_resource=f"Detected in your environment via SBOM", + affected_resource="Detected in your environment via SBOM", action_required=f"Check if your version of {term} ({version}) is affected. Update to the latest patched version.", references=references, ) @@ -208,7 +207,7 @@ def _query_cisa_kev_recent(tech_stack: dict, start_date: datetime) -> list[Threa for vuln in data.get("vulnerabilities", []): date_added = vuln.get("dateAdded", "") try: - added_dt = datetime.strptime(date_added, "%Y-%m-%d").replace(tzinfo=timezone.utc) + added_dt = datetime.strptime(date_added, "%Y-%m-%d").replace(tzinfo=UTC) if added_dt < start_date: continue except ValueError: @@ -317,7 +316,7 @@ def _check_recent_supply_chain(tech_stack: dict, start_date: datetime) -> list[T published=published, description=adv.get("description", "")[:500], affected_component=affected_pkg, - affected_resource=f"Found in your SBOM", + affected_resource="Found in your SBOM", action_required="Update to the patched version immediately.", references=[adv.get("html_url", "")], is_supply_chain=True, diff --git a/src/shasta/trustcenter/config.py b/src/shasta/trustcenter/config.py index caf25c8..da557a3 100644 --- a/src/shasta/trustcenter/config.py +++ b/src/shasta/trustcenter/config.py @@ -10,7 +10,6 @@ import json from dataclasses import dataclass, field from pathlib import Path -from typing import Any @dataclass diff --git a/src/shasta/trustcenter/generator.py b/src/shasta/trustcenter/generator.py index e5608d9..73a38dc 100644 --- a/src/shasta/trustcenter/generator.py +++ b/src/shasta/trustcenter/generator.py @@ -10,7 +10,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -45,7 +45,7 @@ def build_trust_center_context( """ context: dict[str, Any] = { "config": config, - "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), + "generated_at": datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC"), "has_scan_data": False, "soc2_score": None, "iso_score": None, @@ -106,13 +106,9 @@ def build_trust_center_context( context["domain_breakdown"] = scan.summary.by_domain # Data protection pass rates - context["encryption_pass_rate"] = _domain_pass_rate( - context["domain_breakdown"], "encryption" - ) + context["encryption_pass_rate"] = _domain_pass_rate(context["domain_breakdown"], "encryption") context["iam_pass_rate"] = _domain_pass_rate(context["domain_breakdown"], "iam") - context["monitoring_pass_rate"] = _domain_pass_rate( - context["domain_breakdown"], "monitoring" - ) + context["monitoring_pass_rate"] = _domain_pass_rate(context["domain_breakdown"], "monitoring") # Infrastructure info cloud_providers = set() @@ -122,7 +118,9 @@ def build_trust_center_context( cloud_providers.add(cp.value.upper() if hasattr(cp, "value") else str(cp).upper()) context["cloud_providers"] = sorted(cloud_providers) - context["domains_scanned"] = [d.value for d in scan.domains_scanned] if scan.domains_scanned else [] + context["domains_scanned"] = ( + [d.value for d in scan.domains_scanned] if scan.domains_scanned else [] + ) context["scan_date"] = ( scan.completed_at.strftime("%Y-%m-%d") if scan.completed_at else "In progress" ) diff --git a/src/shasta/voice/__main__.py b/src/shasta/voice/__main__.py index 36676b1..f30024a 100644 --- a/src/shasta/voice/__main__.py +++ b/src/shasta/voice/__main__.py @@ -1,4 +1,5 @@ """Entry point for `python -m shasta.voice`.""" + from shasta.voice.cli import main if __name__ == "__main__": diff --git a/src/shasta/voice/app.py b/src/shasta/voice/app.py index 6c393bd..5568bff 100644 --- a/src/shasta/voice/app.py +++ b/src/shasta/voice/app.py @@ -1,4 +1,5 @@ """FastAPI application for the voice console.""" + import os from pathlib import Path diff --git a/src/shasta/voice/cli.py b/src/shasta/voice/cli.py index 90e7612..3d75ac4 100644 --- a/src/shasta/voice/cli.py +++ b/src/shasta/voice/cli.py @@ -1,4 +1,5 @@ """`python -m shasta.voice` entrypoint.""" + import argparse import os import sys @@ -13,7 +14,9 @@ def main() -> int: parser.add_argument("--port", type=int, default=8090) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--no-open", action="store_true", help="don't auto-launch browser") - parser.add_argument("--db", type=Path, default=None, help="path to shasta.db (default: data/shasta.db)") + parser.add_argument( + "--db", type=Path, default=None, help="path to shasta.db (default: data/shasta.db)" + ) args = parser.parse_args() api_key = os.environ.get("OPENAI_API_KEY") @@ -30,6 +33,7 @@ def main() -> int: # Verify the DB has at least one scan from shasta.voice.store import Store + s = Store(db_path=db_path) if not s.has_data(): print(f"✗ {db_path} exists but contains no scan data", file=sys.stderr) @@ -51,6 +55,7 @@ def main() -> int: pass import uvicorn + app = create_app(db_path=db_path) uvicorn.run(app, host=args.host, port=args.port, log_level="info") return 0 diff --git a/src/shasta/voice/models.py b/src/shasta/voice/models.py index d26a494..8d7a9ff 100644 --- a/src/shasta/voice/models.py +++ b/src/shasta/voice/models.py @@ -1,4 +1,5 @@ """Pydantic I/O models for voice tools — JSON-serializable views over Shasta core models.""" + from __future__ import annotations from datetime import datetime diff --git a/src/shasta/voice/observability.py b/src/shasta/voice/observability.py index d74e7cf..faedba1 100644 --- a/src/shasta/voice/observability.py +++ b/src/shasta/voice/observability.py @@ -1,4 +1,5 @@ """Structured logging for tool calls.""" + import json import logging import os diff --git a/src/shasta/voice/realtime_config.py b/src/shasta/voice/realtime_config.py index 52f68f5..8b7c29c 100644 --- a/src/shasta/voice/realtime_config.py +++ b/src/shasta/voice/realtime_config.py @@ -1,4 +1,5 @@ """OpenAI Realtime session configuration: Distiller prompt, 14 tool schemas, VAD.""" + import os from typing import Any @@ -31,24 +32,47 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ { - "type": "function", "name": "list_findings", + "type": "function", + "name": "list_findings", "description": "List compliance findings from the latest scan. Filter by severity, status, domain, cloud, framework, control.", "parameters": { "type": "object", "properties": { - "severity": {"type": "string", "enum": ["critical", "high", "medium", "low", "info"]}, - "status": {"type": "string", "enum": ["pass", "fail", "partial", "not_assessed", "not_applicable"]}, - "domain": {"type": "string", "enum": ["iam", "networking", "encryption", "logging", "compute", "storage", "monitoring", "ai_governance"]}, + "severity": { + "type": "string", + "enum": ["critical", "high", "medium", "low", "info"], + }, + "status": { + "type": "string", + "enum": ["pass", "fail", "partial", "not_assessed", "not_applicable"], + }, + "domain": { + "type": "string", + "enum": [ + "iam", + "networking", + "encryption", + "logging", + "compute", + "storage", + "monitoring", + "ai_governance", + ], + }, "cloud": {"type": "string", "enum": ["aws", "azure"]}, "framework": {"type": "string", "enum": ["soc2", "iso27001", "hipaa"]}, - "control_id": {"type": "string", "description": "e.g., CC6.1 — only meaningful with framework set"}, + "control_id": { + "type": "string", + "description": "e.g., CC6.1 — only meaningful with framework set", + }, "limit": {"type": "integer", "minimum": 1, "maximum": 100}, }, "additionalProperties": False, }, }, { - "type": "function", "name": "get_finding", + "type": "function", + "name": "get_finding", "description": "Get full detail of a single finding by ID — description, remediation, affected resource, control mappings.", "parameters": { "type": "object", @@ -58,7 +82,8 @@ }, }, { - "type": "function", "name": "list_top_blockers", + "type": "function", + "name": "list_top_blockers", "description": "List the highest-severity unresolved findings. Use for 'what should I fix first?' questions.", "parameters": { "type": "object", @@ -67,7 +92,8 @@ }, }, { - "type": "function", "name": "get_resource_findings", + "type": "function", + "name": "get_resource_findings", "description": "List all findings for a specific cloud resource (by ARN or Azure resource ID).", "parameters": { "type": "object", @@ -77,22 +103,30 @@ }, }, { - "type": "function", "name": "get_compliance_score", + "type": "function", + "name": "get_compliance_score", "description": "Get the compliance score for one framework. Use when the user asks about a specific standard.", "parameters": { "type": "object", - "properties": {"framework": {"type": "string", "enum": ["soc2", "iso27001", "hipaa", "iso42001", "eu_ai_act", "ai_governance"]}}, + "properties": { + "framework": { + "type": "string", + "enum": ["soc2", "iso27001", "hipaa", "iso42001", "eu_ai_act", "ai_governance"], + } + }, "required": ["framework"], "additionalProperties": False, }, }, { - "type": "function", "name": "get_multi_framework_score", + "type": "function", + "name": "get_multi_framework_score", "description": "Get scores for ALL frameworks at once. Use for 'how am I doing across the board?' or 'overall posture' questions.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, }, { - "type": "function", "name": "get_score_trend", + "type": "function", + "name": "get_score_trend", "description": "Get score history for a framework across recent scans. Use for 'how does that compare to last week?' questions.", "parameters": { "type": "object", @@ -105,7 +139,8 @@ }, }, { - "type": "function", "name": "get_control_summary", + "type": "function", + "name": "get_control_summary", "description": "Get summary of a specific control (e.g., CC6.1) or all controls in a framework. Returns pass/fail counts + finding IDs.", "parameters": { "type": "object", @@ -118,7 +153,8 @@ }, }, { - "type": "function", "name": "list_scans", + "type": "function", + "name": "list_scans", "description": "List recent scans with summary stats (date, total findings, pass/fail counts).", "parameters": { "type": "object", @@ -127,18 +163,26 @@ }, }, { - "type": "function", "name": "get_latest_scan", + "type": "function", + "name": "get_latest_scan", "description": "Get summary of the most recent scan: when it ran, total findings, severity counts.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, }, { - "type": "function", "name": "list_risk_items", + "type": "function", + "name": "list_risk_items", "description": "List risk register items. Filter by status (open/in_progress/accepted/resolved) or level (high/medium/low).", "parameters": { "type": "object", "properties": { - "account_id": {"type": "string", "description": "Cloud account ID — pass the user's account from latest scan if unknown"}, - "status": {"type": "string", "enum": ["open", "in_progress", "accepted", "resolved"]}, + "account_id": { + "type": "string", + "description": "Cloud account ID — pass the user's account from latest scan if unknown", + }, + "status": { + "type": "string", + "enum": ["open", "in_progress", "accepted", "resolved"], + }, "level": {"type": "string", "enum": ["high", "medium", "low"]}, }, "required": ["account_id"], @@ -146,7 +190,8 @@ }, }, { - "type": "function", "name": "get_risk_item", + "type": "function", + "name": "get_risk_item", "description": "Get a single risk register item by ID.", "parameters": { "type": "object", @@ -156,7 +201,8 @@ }, }, { - "type": "function", "name": "add_risk_item", + "type": "function", + "name": "add_risk_item", "description": "Add a new risk to the risk register. Use when the user explicitly asks to record a risk.", "parameters": { "type": "object", @@ -167,25 +213,46 @@ "category": {"type": "string", "description": "e.g., iam, logging, encryption"}, "likelihood": {"type": "string", "enum": ["low", "medium", "high"]}, "impact": {"type": "string", "enum": ["low", "medium", "high"]}, - "treatment": {"type": "string", "enum": ["mitigate", "accept", "transfer", "avoid"]}, + "treatment": { + "type": "string", + "enum": ["mitigate", "accept", "transfer", "avoid"], + }, "treatment_plan": {"type": "string"}, - "related_finding": {"type": "string", "description": "Optional finding ID this risk relates to"}, + "related_finding": { + "type": "string", + "description": "Optional finding ID this risk relates to", + }, }, - "required": ["account_id", "title", "description", "category", "likelihood", "impact", "treatment"], + "required": [ + "account_id", + "title", + "description", + "category", + "likelihood", + "impact", + "treatment", + ], "additionalProperties": False, }, }, { - "type": "function", "name": "update_risk", + "type": "function", + "name": "update_risk", "description": "Update an existing risk register item. Pass risk_id plus any fields to change.", "parameters": { "type": "object", "properties": { "risk_id": {"type": "string"}, "account_id": {"type": "string"}, - "treatment": {"type": "string", "enum": ["mitigate", "accept", "transfer", "avoid"]}, + "treatment": { + "type": "string", + "enum": ["mitigate", "accept", "transfer", "avoid"], + }, "treatment_plan": {"type": "string"}, - "status": {"type": "string", "enum": ["open", "in_progress", "accepted", "resolved"]}, + "status": { + "type": "string", + "enum": ["open", "in_progress", "accepted", "resolved"], + }, "review_notes": {"type": "string"}, }, "required": ["risk_id"], diff --git a/src/shasta/voice/session.py b/src/shasta/voice/session.py index 3ca019f..a497465 100644 --- a/src/shasta/voice/session.py +++ b/src/shasta/voice/session.py @@ -1,4 +1,5 @@ """Ephemeral OpenAI Realtime token endpoint.""" + import os import httpx @@ -27,7 +28,10 @@ def mint_session_token() -> dict: raise HTTPException(status_code=502, detail=f"OpenAI request failed: {e}") from e if response.status_code != 200: - raise HTTPException(status_code=502, detail=f"OpenAI session creation failed ({response.status_code}): {response.text[:200]}") + raise HTTPException( + status_code=502, + detail=f"OpenAI session creation failed ({response.status_code}): {response.text[:200]}", + ) body = response.json() return { diff --git a/src/shasta/voice/store.py b/src/shasta/voice/store.py index 42cb9a4..4fe39b9 100644 --- a/src/shasta/voice/store.py +++ b/src/shasta/voice/store.py @@ -3,6 +3,7 @@ The only place in the voice module that touches Shasta core. Replace the body of any read method to swap data sources without touching tool code. """ + from __future__ import annotations from datetime import UTC, datetime @@ -80,7 +81,9 @@ def _finding_to_detail(f: Finding) -> FindingDetailView: region=f.region, account_id=f.account_id, details=dict(f.details), - timestamp=f.timestamp if isinstance(f.timestamp, datetime) else datetime.fromisoformat(f.timestamp), + timestamp=f.timestamp + if isinstance(f.timestamp, datetime) + else datetime.fromisoformat(f.timestamp), ) @@ -127,8 +130,12 @@ def get_latest_scan(self) -> ScanSummaryView | None: return ScanSummaryView( scan_id=scan.id, account_id=scan.account_id, - cloud_provider=scan.cloud_provider.value if hasattr(scan.cloud_provider, "value") else scan.cloud_provider, - completed_at=scan.completed_at if isinstance(scan.completed_at, datetime) else (datetime.fromisoformat(scan.completed_at) if scan.completed_at else None), + cloud_provider=scan.cloud_provider.value + if hasattr(scan.cloud_provider, "value") + else scan.cloud_provider, + completed_at=scan.completed_at + if isinstance(scan.completed_at, datetime) + else (datetime.fromisoformat(scan.completed_at) if scan.completed_at else None), total_findings=summary.total_findings if summary else len(scan.findings), critical_count=summary.critical_count if summary else 0, high_count=summary.high_count if summary else 0, @@ -143,24 +150,29 @@ def list_scans(self, limit: int = 10) -> list[ScanSummaryView]: out: list[ScanSummaryView] = [] for row in history: import json as _json + summary_blob = row.get("summary") if summary_blob: s = _json.loads(summary_blob) else: s = {} - out.append(ScanSummaryView( - scan_id=row["id"], - account_id=row["account_id"], - cloud_provider="aws", # history rows don't carry cloud_provider in this query - completed_at=datetime.fromisoformat(row["completed_at"]) if row.get("completed_at") else None, - total_findings=s.get("total_findings", 0), - critical_count=s.get("critical_count", 0), - high_count=s.get("high_count", 0), - medium_count=s.get("medium_count", 0), - low_count=s.get("low_count", 0), - passed=s.get("passed", 0), - failed=s.get("failed", 0), - )) + out.append( + ScanSummaryView( + scan_id=row["id"], + account_id=row["account_id"], + cloud_provider="aws", # history rows don't carry cloud_provider in this query + completed_at=datetime.fromisoformat(row["completed_at"]) + if row.get("completed_at") + else None, + total_findings=s.get("total_findings", 0), + critical_count=s.get("critical_count", 0), + high_count=s.get("high_count", 0), + medium_count=s.get("medium_count", 0), + low_count=s.get("low_count", 0), + passed=s.get("passed", 0), + failed=s.get("failed", 0), + ) + ) return out # ---- Findings ---- @@ -188,14 +200,27 @@ def list_findings( if cloud: results = [f for f in results if f.cloud_provider.value == cloud] if framework: - attr = {"soc2": "soc2_controls", "iso27001": "iso27001_controls", "hipaa": "hipaa_controls"}.get(framework) + attr = { + "soc2": "soc2_controls", + "iso27001": "iso27001_controls", + "hipaa": "hipaa_controls", + }.get(framework) if attr: results = [f for f in results if getattr(f, attr)] if control_id and framework: - attr = {"soc2": "soc2_controls", "iso27001": "iso27001_controls", "hipaa": "hipaa_controls"}.get(framework) + attr = { + "soc2": "soc2_controls", + "iso27001": "iso27001_controls", + "hipaa": "hipaa_controls", + }.get(framework) if attr: results = [f for f in results if control_id in getattr(f, attr)] - results.sort(key=lambda f: (_SEVERITY_RANK.get(f.severity.value, 99), 0 if f.status.value == "fail" else 1)) + results.sort( + key=lambda f: ( + _SEVERITY_RANK.get(f.severity.value, 99), + 0 if f.status.value == "fail" else 1, + ) + ) if limit: results = results[:limit] return [_finding_to_summary(f) for f in results] @@ -245,6 +270,7 @@ def get_compliance_score(self, framework: Framework) -> ComplianceScoreView | No # iso42001 / eu_ai_act / ai_governance — treat as AI governance for now try: from shasta.compliance.ai.scorer import calculate_ai_governance_score + ai_findings = [f for f in scan.findings if f.domain.value == "ai_governance"] if not ai_findings: return None @@ -287,19 +313,27 @@ def get_score_trend(self, framework: Framework, limit: int = 10) -> ScoreTrendVi _enrich_all(full) if scorer is not None: s = scorer(full) - points.append({ - "scan_id": tmp_scan_id, - "completed_at": row.get("completed_at"), - "score_percentage": getattr(s, "score_percentage", 0.0), - }) + points.append( + { + "scan_id": tmp_scan_id, + "completed_at": row.get("completed_at"), + "score_percentage": getattr(s, "score_percentage", 0.0), + } + ) # Oldest first for delta math points.sort(key=lambda p: p["completed_at"] or "") - delta = (points[-1]["score_percentage"] - points[0]["score_percentage"]) if len(points) >= 2 else 0.0 + delta = ( + (points[-1]["score_percentage"] - points[0]["score_percentage"]) + if len(points) >= 2 + else 0.0 + ) return ScoreTrendView(framework=framework, points=points, delta=round(delta, 1)) # ---- Controls ---- - def get_control_summary(self, framework: Framework, control_id: str | None = None) -> list[ControlSummaryView]: + def get_control_summary( + self, framework: Framework, control_id: str | None = None + ) -> list[ControlSummaryView]: scan = self._latest_scan() if scan is None: return [] @@ -311,22 +345,25 @@ def get_control_summary(self, framework: Framework, control_id: str | None = Non for cid, data in summary.items(): if control_id and cid != control_id: continue - out.append(ControlSummaryView( - framework=framework, - control_id=cid, - title=data.get("title", cid), - overall_status=data.get("overall_status", "not_assessed"), - pass_count=data.get("pass_count", 0), - fail_count=data.get("fail_count", 0), - partial_count=data.get("partial_count", 0), - finding_ids=[f.id for f in data.get("findings", [])], - )) + out.append( + ControlSummaryView( + framework=framework, + control_id=cid, + title=data.get("title", cid), + overall_status=data.get("overall_status", "not_assessed"), + pass_count=data.get("pass_count", 0), + fail_count=data.get("fail_count", 0), + partial_count=data.get("partial_count", 0), + finding_ids=[f.id for f in data.get("findings", [])], + ) + ) return out # ---- Risks ---- def _risk_row_to_view(self, row: dict) -> RiskItemView: import json as _json + return RiskItemView( risk_id=row["risk_id"], title=row["title"], @@ -343,7 +380,9 @@ def _risk_row_to_view(self, row: dict) -> RiskItemView: related_finding=row.get("related_finding"), ) - def list_risk_items(self, account_id: str, status: str | None = None, level: str | None = None) -> list[RiskItemView]: + def list_risk_items( + self, account_id: str, status: str | None = None, level: str | None = None + ) -> list[RiskItemView]: rows = self._db.get_risk_items(account_id) if status: rows = [r for r in rows if r.get("status") == status] @@ -374,6 +413,7 @@ def add_risk_item( ) -> ActionResult: from datetime import datetime from uuid import uuid4 + risk_id = f"R-{uuid4().hex[:8].upper()}" # Score: simple LxI matrix on a 1-3 scale → 1-9 scale = {"low": 1, "medium": 2, "high": 3} @@ -386,6 +426,7 @@ def add_risk_item( # Build a record matching the columns expected by save_risk_items # save_risk_items expects an object with attributes — wrap in a SimpleNamespace from types import SimpleNamespace + item = SimpleNamespace( risk_id=risk_id, title=title, @@ -426,6 +467,7 @@ def update_risk( return ActionResult(success=False, message=f"Risk {risk_id} not found") from datetime import datetime from types import SimpleNamespace + # Recreate the row with overrides — save_risk_items uses INSERT OR REPLACE scale = {"low": 1, "medium": 2, "high": 3} likelihood_score = scale.get(existing.likelihood.lower(), 2) @@ -443,11 +485,15 @@ def update_risk( risk_level=level, owner=None, treatment=treatment if treatment is not None else existing.treatment, - treatment_plan=treatment_plan if treatment_plan is not None else existing.treatment_plan, + treatment_plan=treatment_plan + if treatment_plan is not None + else existing.treatment_plan, status=status if status is not None else existing.status, soc2_controls=existing.soc2_controls, related_finding=existing.related_finding, - created_date=datetime.now(UTC).isoformat(), # save_risk_items doesn't preserve this on REPLACE + created_date=datetime.now( + UTC + ).isoformat(), # save_risk_items doesn't preserve this on REPLACE last_reviewed=datetime.now(UTC).isoformat(), review_notes=review_notes if review_notes is not None else None, ) diff --git a/src/shasta/voice/tools/controls.py b/src/shasta/voice/tools/controls.py index e470949..486dfb0 100644 --- a/src/shasta/voice/tools/controls.py +++ b/src/shasta/voice/tools/controls.py @@ -1,8 +1,14 @@ """Tool functions for control summaries.""" + from typing import Any from shasta.voice.store import Store -def get_control_summary(*, store: Store, framework: str, control_id: str | None = None) -> list[dict[str, Any]]: - return [c.model_dump(mode="json") for c in store.get_control_summary(framework, control_id=control_id)] # type: ignore[arg-type] +def get_control_summary( + *, store: Store, framework: str, control_id: str | None = None +) -> list[dict[str, Any]]: + return [ + c.model_dump(mode="json") + for c in store.get_control_summary(framework, control_id=control_id) + ] # type: ignore[arg-type] diff --git a/src/shasta/voice/tools/findings.py b/src/shasta/voice/tools/findings.py index 279cf4c..69c7a37 100644 --- a/src/shasta/voice/tools/findings.py +++ b/src/shasta/voice/tools/findings.py @@ -1,4 +1,5 @@ """Tool functions for finding queries.""" + from typing import Any from shasta.voice.store import Store @@ -16,8 +17,13 @@ def list_findings( limit: int | None = None, ) -> list[dict[str, Any]]: items = store.list_findings( - severity=severity, status=status, domain=domain, cloud=cloud, - framework=framework, control_id=control_id, limit=limit, + severity=severity, + status=status, + domain=domain, + cloud=cloud, + framework=framework, + control_id=control_id, + limit=limit, ) return [i.model_dump(mode="json") for i in items] diff --git a/src/shasta/voice/tools/risks.py b/src/shasta/voice/tools/risks.py index 6e06879..61ad257 100644 --- a/src/shasta/voice/tools/risks.py +++ b/src/shasta/voice/tools/risks.py @@ -1,14 +1,22 @@ """Tool functions for risk-register operations.""" + from typing import Any from shasta.voice.store import Store -def list_risk_items(*, store: Store, account_id: str, status: str | None = None, level: str | None = None) -> list[dict[str, Any]]: - return [r.model_dump(mode="json") for r in store.list_risk_items(account_id, status=status, level=level)] +def list_risk_items( + *, store: Store, account_id: str, status: str | None = None, level: str | None = None +) -> list[dict[str, Any]]: + return [ + r.model_dump(mode="json") + for r in store.list_risk_items(account_id, status=status, level=level) + ] -def get_risk_item(*, store: Store, risk_id: str, account_id: str = "123456789012") -> dict[str, Any]: +def get_risk_item( + *, store: Store, risk_id: str, account_id: str = "123456789012" +) -> dict[str, Any]: r = store.get_risk_item(risk_id, account_id=account_id) if r is None: return {"error": "risk_not_found", "risk_id": risk_id} @@ -29,9 +37,15 @@ def add_risk_item( related_finding: str | None = None, ) -> dict[str, Any]: return store.add_risk_item( - account_id=account_id, title=title, description=description, category=category, - likelihood=likelihood, impact=impact, treatment=treatment, - treatment_plan=treatment_plan, related_finding=related_finding, + account_id=account_id, + title=title, + description=description, + category=category, + likelihood=likelihood, + impact=impact, + treatment=treatment, + treatment_plan=treatment_plan, + related_finding=related_finding, ).model_dump(mode="json") @@ -46,6 +60,10 @@ def update_risk( account_id: str = "123456789012", ) -> dict[str, Any]: return store.update_risk( - risk_id=risk_id, treatment=treatment, treatment_plan=treatment_plan, - status=status, review_notes=review_notes, account_id=account_id, + risk_id=risk_id, + treatment=treatment, + treatment_plan=treatment_plan, + status=status, + review_notes=review_notes, + account_id=account_id, ).model_dump(mode="json") diff --git a/src/shasta/voice/tools/router.py b/src/shasta/voice/tools/router.py index 6dd6762..2afd14c 100644 --- a/src/shasta/voice/tools/router.py +++ b/src/shasta/voice/tools/router.py @@ -1,4 +1,5 @@ """HTTP endpoints for tool calls. Browser relays OpenAI tool calls here.""" + import time from typing import Literal @@ -108,85 +109,138 @@ class UpdateRiskReq(BaseModel): # ---------- endpoints ---------- + @router.post("/list_findings") def list_findings(req: ListFindingsReq, request: Request): - return _timed("list_findings", req.model_dump(exclude_none=True), - lambda: findings_tool.list_findings(store=_store(request), **req.model_dump(exclude_none=True))) + return _timed( + "list_findings", + req.model_dump(exclude_none=True), + lambda: findings_tool.list_findings( + store=_store(request), **req.model_dump(exclude_none=True) + ), + ) @router.post("/get_finding") def get_finding(req: IdReq, request: Request): - return _timed("get_finding", {"finding_id": req.finding_id}, - lambda: findings_tool.get_finding(store=_store(request), finding_id=req.finding_id or "")) + return _timed( + "get_finding", + {"finding_id": req.finding_id}, + lambda: findings_tool.get_finding(store=_store(request), finding_id=req.finding_id or ""), + ) @router.post("/list_top_blockers") def list_top_blockers(req: LimitReq, request: Request): - return _timed("list_top_blockers", {"limit": req.limit}, - lambda: findings_tool.list_top_blockers(store=_store(request), limit=req.limit)) + return _timed( + "list_top_blockers", + {"limit": req.limit}, + lambda: findings_tool.list_top_blockers(store=_store(request), limit=req.limit), + ) @router.post("/get_resource_findings") def get_resource_findings(req: IdReq, request: Request): - return _timed("get_resource_findings", {"resource_id": req.resource_id}, - lambda: findings_tool.get_resource_findings(store=_store(request), resource_id=req.resource_id or "")) + return _timed( + "get_resource_findings", + {"resource_id": req.resource_id}, + lambda: findings_tool.get_resource_findings( + store=_store(request), resource_id=req.resource_id or "" + ), + ) @router.post("/get_compliance_score") def get_compliance_score(req: GetComplianceScoreReq, request: Request): - return _timed("get_compliance_score", req.model_dump(), - lambda: scores_tool.get_compliance_score(store=_store(request), framework=req.framework)) + return _timed( + "get_compliance_score", + req.model_dump(), + lambda: scores_tool.get_compliance_score(store=_store(request), framework=req.framework), + ) @router.post("/get_multi_framework_score") def get_multi_framework_score(request: Request): - return _timed("get_multi_framework_score", {}, - lambda: scores_tool.get_multi_framework_score(store=_store(request))) + return _timed( + "get_multi_framework_score", + {}, + lambda: scores_tool.get_multi_framework_score(store=_store(request)), + ) @router.post("/get_score_trend") def get_score_trend(req: GetScoreTrendReq, request: Request): - return _timed("get_score_trend", req.model_dump(), - lambda: scores_tool.get_score_trend(store=_store(request), framework=req.framework, limit=req.limit)) + return _timed( + "get_score_trend", + req.model_dump(), + lambda: scores_tool.get_score_trend( + store=_store(request), framework=req.framework, limit=req.limit + ), + ) @router.post("/get_control_summary") def get_control_summary(req: GetControlSummaryReq, request: Request): - return _timed("get_control_summary", req.model_dump(exclude_none=True), - lambda: controls_tool.get_control_summary(store=_store(request), framework=req.framework, control_id=req.control_id)) + return _timed( + "get_control_summary", + req.model_dump(exclude_none=True), + lambda: controls_tool.get_control_summary( + store=_store(request), framework=req.framework, control_id=req.control_id + ), + ) @router.post("/list_scans") def list_scans(req: LimitReq, request: Request): - return _timed("list_scans", {"limit": req.limit}, - lambda: scans_tool.list_scans(store=_store(request), limit=req.limit)) + return _timed( + "list_scans", + {"limit": req.limit}, + lambda: scans_tool.list_scans(store=_store(request), limit=req.limit), + ) @router.post("/get_latest_scan") def get_latest_scan(request: Request): - return _timed("get_latest_scan", {}, - lambda: scans_tool.get_latest_scan(store=_store(request))) + return _timed("get_latest_scan", {}, lambda: scans_tool.get_latest_scan(store=_store(request))) @router.post("/list_risk_items") def list_risk_items(req: ListRisksReq, request: Request): - return _timed("list_risk_items", req.model_dump(exclude_none=True), - lambda: risks_tool.list_risk_items(store=_store(request), account_id=req.account_id, status=req.status, level=req.level)) + return _timed( + "list_risk_items", + req.model_dump(exclude_none=True), + lambda: risks_tool.list_risk_items( + store=_store(request), account_id=req.account_id, status=req.status, level=req.level + ), + ) @router.post("/get_risk_item") def get_risk_item(req: GetRiskReq, request: Request): - return _timed("get_risk_item", req.model_dump(), - lambda: risks_tool.get_risk_item(store=_store(request), risk_id=req.risk_id, account_id=req.account_id)) + return _timed( + "get_risk_item", + req.model_dump(), + lambda: risks_tool.get_risk_item( + store=_store(request), risk_id=req.risk_id, account_id=req.account_id + ), + ) @router.post("/add_risk_item") def add_risk_item(req: AddRiskReq, request: Request): - return _timed("add_risk_item", req.model_dump(exclude_none=True), - lambda: risks_tool.add_risk_item(store=_store(request), **req.model_dump(exclude_none=True))) + return _timed( + "add_risk_item", + req.model_dump(exclude_none=True), + lambda: risks_tool.add_risk_item( + store=_store(request), **req.model_dump(exclude_none=True) + ), + ) @router.post("/update_risk") def update_risk(req: UpdateRiskReq, request: Request): - return _timed("update_risk", req.model_dump(exclude_none=True), - lambda: risks_tool.update_risk(store=_store(request), **req.model_dump(exclude_none=True))) + return _timed( + "update_risk", + req.model_dump(exclude_none=True), + lambda: risks_tool.update_risk(store=_store(request), **req.model_dump(exclude_none=True)), + ) diff --git a/src/shasta/voice/tools/scans.py b/src/shasta/voice/tools/scans.py index 2b6d783..ca079e0 100644 --- a/src/shasta/voice/tools/scans.py +++ b/src/shasta/voice/tools/scans.py @@ -1,4 +1,5 @@ """Tool functions for scan queries.""" + from typing import Any from shasta.voice.store import Store diff --git a/src/shasta/voice/tools/scores.py b/src/shasta/voice/tools/scores.py index 6614d9f..44ef27c 100644 --- a/src/shasta/voice/tools/scores.py +++ b/src/shasta/voice/tools/scores.py @@ -1,4 +1,5 @@ """Tool functions for compliance scores.""" + from typing import Any from shasta.voice.store import Store @@ -8,10 +9,18 @@ def get_compliance_score(*, store: Store, framework: str) -> dict[str, Any]: if framework not in _VALID_FRAMEWORKS: - return {"error": "invalid_framework", "framework": framework, "valid": sorted(_VALID_FRAMEWORKS)} + return { + "error": "invalid_framework", + "framework": framework, + "valid": sorted(_VALID_FRAMEWORKS), + } score = store.get_compliance_score(framework) # type: ignore[arg-type] if score is None: - return {"error": "framework_not_applicable", "framework": framework, "reason": "no_findings_or_scorer_unavailable"} + return { + "error": "framework_not_applicable", + "framework": framework, + "reason": "no_findings_or_scorer_unavailable", + } return score.model_dump(mode="json") diff --git a/src/shasta/workflows/access_review.py b/src/shasta/workflows/access_review.py index 59e9710..9b195dd 100644 --- a/src/shasta/workflows/access_review.py +++ b/src/shasta/workflows/access_review.py @@ -11,7 +11,7 @@ import io import time from dataclasses import dataclass, field -from datetime import datetime, timezone, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -55,7 +55,7 @@ def run_access_review(client: AWSClient) -> AccessReviewReport: """Run a comprehensive IAM access review.""" iam = client.client("iam") account_id = client.account_info.account_id if client.account_info else "unknown" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) # Generate credential report for _ in range(10): diff --git a/src/shasta/workflows/drift.py b/src/shasta/workflows/drift.py index 0172c24..cd4ab55 100644 --- a/src/shasta/workflows/drift.py +++ b/src/shasta/workflows/drift.py @@ -10,9 +10,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import datetime -from shasta.compliance.scorer import calculate_score, ComplianceScore +from shasta.compliance.scorer import ComplianceScore, calculate_score from shasta.evidence.models import ComplianceStatus, Finding, ScanResult @@ -174,8 +174,8 @@ def format_drift_summary(report: DriftReport) -> str: "", "## Score Change", "", - f"| Metric | Previous | Current | Delta |", - f"|--------|----------|---------|-------|", + "| Metric | Previous | Current | Delta |", + "|--------|----------|---------|-------|", f"| Score | {report.previous_score.score_percentage}% | {report.current_score.score_percentage}% | {'+' if report.score_delta >= 0 else ''}{report.score_delta}% |", f"| Grade | {report.previous_score.grade} | {report.current_score.grade} | {'improved' if report.score_delta > 0 else 'declined' if report.score_delta < 0 else 'unchanged'} |", f"| Failed findings | {report.previous_score.findings_failed} | {report.current_score.findings_failed} | {'+' if report.current_score.findings_failed > report.previous_score.findings_failed else ''}{report.current_score.findings_failed - report.previous_score.findings_failed} |", diff --git a/src/shasta/workflows/risk_register.py b/src/shasta/workflows/risk_register.py index e6e12a0..857b931 100644 --- a/src/shasta/workflows/risk_register.py +++ b/src/shasta/workflows/risk_register.py @@ -10,13 +10,11 @@ from __future__ import annotations -import json from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any -from shasta.evidence.models import ComplianceStatus, Finding, Severity +from shasta.evidence.models import ComplianceStatus, Finding LIKELIHOOD_VALUES = {"low": 1, "medium": 2, "high": 3} IMPACT_VALUES = {"low": 1, "medium": 2, "high": 3} @@ -391,7 +389,7 @@ def calculate_risk(likelihood: str, impact: str) -> tuple[int, str]: def auto_seed_from_findings(findings: list[Finding], account_id: str) -> list[RiskItem]: """Convert failing scan findings into risk register items.""" - now = datetime.now(timezone.utc).strftime("%Y-%m-%d") + now = datetime.now(UTC).strftime("%Y-%m-%d") risks = [] seen_checks = set() counter = 1 @@ -437,7 +435,7 @@ def auto_seed_from_findings(findings: list[Finding], account_id: str) -> list[Ri def build_register(items: list[RiskItem], account_id: str) -> RiskRegister: """Build a RiskRegister summary from a list of items.""" - now = datetime.now(timezone.utc).strftime("%Y-%m-%d") + now = datetime.now(UTC).strftime("%Y-%m-%d") active = [r for r in items if r.status in ("open", "in_progress")] return RiskRegister( diff --git a/tests/test_aws/test_aws_checks_functional.py b/tests/test_aws/test_aws_checks_functional.py index 8f92d19..d2ce0e4 100644 --- a/tests/test_aws/test_aws_checks_functional.py +++ b/tests/test_aws/test_aws_checks_functional.py @@ -8,13 +8,15 @@ from __future__ import annotations import json -from datetime import datetime, timedelta, timezone import boto3 -import pytest from moto import mock_aws from shasta.aws.client import AWSClient +from shasta.aws.encryption import ( + check_ebs_volumes, + check_rds_encryption, +) from shasta.aws.iam import ( check_access_key_rotation, check_password_policy, @@ -22,24 +24,19 @@ check_user_direct_policies, check_user_mfa, ) +from shasta.aws.networking import ( + check_default_security_groups, + check_security_groups, + check_vpc_flow_logs, +) from shasta.aws.storage import ( check_s3_encryption, check_s3_public_access_block, check_s3_ssl_only, check_s3_versioning, ) -from shasta.aws.encryption import ( - check_ebs_volumes, - check_rds_encryption, -) -from shasta.aws.networking import ( - check_default_security_groups, - check_security_groups, - check_vpc_flow_logs, -) from shasta.evidence.models import ComplianceStatus - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -257,10 +254,12 @@ def test_user_with_direct_policy_fails(self): # Create a custom policy (moto doesn't ship AWS managed policies) policy_resp = iam.create_policy( PolicyName="TestReadOnly", - PolicyDocument=json.dumps({ - "Version": "2012-10-17", - "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}], - }), + PolicyDocument=json.dumps( + { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}], + } + ), ) iam.attach_user_policy( UserName="policy-user", @@ -304,9 +303,7 @@ def test_encrypted_bucket_passes(self): s3.put_bucket_encryption( Bucket="encrypted-bucket", ServerSideEncryptionConfiguration={ - "Rules": [ - {"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}} - ] + "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}] }, ) diff --git a/tests/test_aws/test_aws_sweep_smoke.py b/tests/test_aws/test_aws_sweep_smoke.py index 5df9449..37d6918 100644 --- a/tests/test_aws/test_aws_sweep_smoke.py +++ b/tests/test_aws/test_aws_sweep_smoke.py @@ -20,7 +20,6 @@ Severity, ) - NEW_AWS_MODULES = [ "shasta.aws.databases", "shasta.aws.serverless", diff --git a/tests/test_aws/test_client.py b/tests/test_aws/test_client.py index 5d1ec0e..7e38a7e 100644 --- a/tests/test_aws/test_client.py +++ b/tests/test_aws/test_client.py @@ -1,9 +1,8 @@ """Tests for AWS client module.""" -import pytest from moto import mock_aws -from shasta.aws.client import AWSClient, AWSClientError +from shasta.aws.client import AWSClient @mock_aws diff --git a/tests/test_aws/test_models.py b/tests/test_aws/test_models.py index 984672e..6d61166 100644 --- a/tests/test_aws/test_models.py +++ b/tests/test_aws/test_models.py @@ -3,7 +3,6 @@ from shasta.evidence.models import ( CheckDomain, ComplianceStatus, - Evidence, Finding, ScanResult, ScanSummary, diff --git a/tests/test_azure/conftest.py b/tests/test_azure/conftest.py index 99cef21..fe6a9cd 100644 --- a/tests/test_azure/conftest.py +++ b/tests/test_azure/conftest.py @@ -6,7 +6,7 @@ from __future__ import annotations -from unittest.mock import MagicMock, PropertyMock +from unittest.mock import MagicMock import pytest diff --git a/tests/test_azure/test_azure_checks_functional.py b/tests/test_azure/test_azure_checks_functional.py index f1ca605..38b6636 100644 --- a/tests/test_azure/test_azure_checks_functional.py +++ b/tests/test_azure/test_azure_checks_functional.py @@ -10,10 +10,7 @@ from unittest.mock import MagicMock, patch -import pytest - from shasta.evidence.models import ComplianceStatus, Severity - from tests.test_azure.conftest import ( make_mock_azure_client, make_mock_disk, @@ -24,7 +21,6 @@ make_nsg_rule, ) - # --------------------------------------------------------------------------- # Storage checks # --------------------------------------------------------------------------- diff --git a/tests/test_azure/test_smoke.py b/tests/test_azure/test_smoke.py index c9536fc..bea5c99 100644 --- a/tests/test_azure/test_smoke.py +++ b/tests/test_azure/test_smoke.py @@ -20,7 +20,6 @@ from shasta.evidence.models import CheckDomain, CloudProvider, Finding, Severity - AZURE_MODULES = [ "shasta.azure.iam", "shasta.azure.storage", diff --git a/tests/test_compliance/test_hipaa_generator.py b/tests/test_compliance/test_hipaa_generator.py index e039e29..623c255 100644 --- a/tests/test_compliance/test_hipaa_generator.py +++ b/tests/test_compliance/test_hipaa_generator.py @@ -64,8 +64,6 @@ def test_list_hipaa_policies_shape(): def test_generate_all_writes_six_files(tmp_path): - paths = generate_all_hipaa_policies( - company_name="Testaco Health", output_path=tmp_path - ) + paths = generate_all_hipaa_policies(company_name="Testaco Health", output_path=tmp_path) assert len(paths) == 6 assert all(p.exists() and p.stat().st_size > 0 for p in paths) diff --git a/tests/test_compliance/test_iso27001_scorer.py b/tests/test_compliance/test_iso27001_scorer.py index e5edff5..2c3d540 100644 --- a/tests/test_compliance/test_iso27001_scorer.py +++ b/tests/test_compliance/test_iso27001_scorer.py @@ -1,15 +1,12 @@ """Tests for ISO 27001 compliance scorer.""" -import pytest - -from shasta.compliance.iso27001_scorer import calculate_iso27001_score, ISO27001Score -from shasta.compliance.iso27001_mapper import enrich_findings_with_iso27001 +from shasta.compliance.iso27001_scorer import ISO27001Score, calculate_iso27001_score from shasta.evidence.models import ( - Finding, CheckDomain, + CloudProvider, ComplianceStatus, + Finding, Severity, - CloudProvider, ) diff --git a/tests/test_compliance/test_mapper.py b/tests/test_compliance/test_mapper.py index 3e1b9fb..316b874 100644 --- a/tests/test_compliance/test_mapper.py +++ b/tests/test_compliance/test_mapper.py @@ -1,15 +1,13 @@ """Tests for SOC 2 mapper (enrich_findings_with_controls and get_control_summary).""" -import pytest - -from shasta.compliance.mapper import enrich_findings_with_controls, get_control_summary from shasta.compliance.framework import SOC2_CONTROLS +from shasta.compliance.mapper import enrich_findings_with_controls, get_control_summary from shasta.evidence.models import ( - Finding, CheckDomain, + CloudProvider, ComplianceStatus, + Finding, Severity, - CloudProvider, ) diff --git a/tests/test_compliance/test_scorer.py b/tests/test_compliance/test_scorer.py index be99446..d37fb35 100644 --- a/tests/test_compliance/test_scorer.py +++ b/tests/test_compliance/test_scorer.py @@ -1,14 +1,12 @@ """Tests for SOC 2 compliance scorer.""" -import pytest - -from shasta.compliance.scorer import calculate_score, ComplianceScore +from shasta.compliance.scorer import ComplianceScore, calculate_score from shasta.evidence.models import ( - Finding, CheckDomain, + CloudProvider, ComplianceStatus, + Finding, Severity, - CloudProvider, ) diff --git a/tests/test_compliance/test_status_walker.py b/tests/test_compliance/test_status_walker.py index 3cc6a47..34fe753 100644 --- a/tests/test_compliance/test_status_walker.py +++ b/tests/test_compliance/test_status_walker.py @@ -43,9 +43,7 @@ def test_policy_only_control_stays_requires_policy_with_passes_too(): def test_automated_control_still_fails_on_failing_finding(): """Controls with automated checks mapped in the framework still fail normally.""" - data = _agg( - requires_policy=True, has_automated_checks=True, fail_count=1, pass_count=2 - ) + data = _agg(requires_policy=True, has_automated_checks=True, fail_count=1, pass_count=2) assert decide_control_status(data) == "fail" diff --git a/tests/test_gcp/conftest.py b/tests/test_gcp/conftest.py index 211c701..44cbdc0 100644 --- a/tests/test_gcp/conftest.py +++ b/tests/test_gcp/conftest.py @@ -2,12 +2,10 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest -from shasta.evidence.models import CheckDomain, CloudProvider - @pytest.fixture() def mock_gcp_client(): diff --git a/tests/test_gcp/test_gcp_checks_functional.py b/tests/test_gcp/test_gcp_checks_functional.py index c7f2c8c..b64e04d 100644 --- a/tests/test_gcp/test_gcp_checks_functional.py +++ b/tests/test_gcp/test_gcp_checks_functional.py @@ -14,7 +14,6 @@ from shasta.evidence.models import ComplianceStatus - PROJECT_ID = "test-project" REGION = "us-central1" @@ -53,9 +52,9 @@ def test_no_primitive_roles_pass(self): from shasta.gcp.iam import check_primitive_roles_not_used client = _make_client() - self._setup_iam_policy(client, [ - {"role": "roles/compute.instanceAdmin", "members": ["user:alice@example.com"]} - ]) + self._setup_iam_policy( + client, [{"role": "roles/compute.instanceAdmin", "members": ["user:alice@example.com"]}] + ) findings = check_primitive_roles_not_used(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -64,9 +63,9 @@ def test_owner_binding_fails(self): from shasta.gcp.iam import check_primitive_roles_not_used client = _make_client() - self._setup_iam_policy(client, [ - {"role": "roles/owner", "members": ["user:alice@example.com"]} - ]) + self._setup_iam_policy( + client, [{"role": "roles/owner", "members": ["user:alice@example.com"]}] + ) findings = check_primitive_roles_not_used(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings, "Should flag owner binding as FAIL" @@ -75,9 +74,9 @@ def test_editor_binding_fails(self): from shasta.gcp.iam import check_primitive_roles_not_used client = _make_client() - self._setup_iam_policy(client, [ - {"role": "roles/editor", "members": ["user:bob@example.com"]} - ]) + self._setup_iam_policy( + client, [{"role": "roles/editor", "members": ["user:bob@example.com"]}] + ) findings = check_primitive_roles_not_used(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -108,12 +107,15 @@ def test_sa_with_editor_fails(self): from shasta.gcp.iam import check_service_account_not_admin client = _make_client() - self._setup_iam_policy(client, [ - { - "role": "roles/editor", - "members": ["serviceAccount:my-sa@test-project.iam.gserviceaccount.com"], - } - ]) + self._setup_iam_policy( + client, + [ + { + "role": "roles/editor", + "members": ["serviceAccount:my-sa@test-project.iam.gserviceaccount.com"], + } + ], + ) findings = check_service_account_not_admin(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -122,12 +124,15 @@ def test_sa_with_narrow_role_passes(self): from shasta.gcp.iam import check_service_account_not_admin client = _make_client() - self._setup_iam_policy(client, [ - { - "role": "roles/storage.objectViewer", - "members": ["serviceAccount:my-sa@test-project.iam.gserviceaccount.com"], - } - ]) + self._setup_iam_policy( + client, + [ + { + "role": "roles/storage.objectViewer", + "members": ["serviceAccount:my-sa@test-project.iam.gserviceaccount.com"], + } + ], + ) findings = check_service_account_not_admin(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -145,9 +150,7 @@ def test_allusers_binding_fails(self): from shasta.gcp.iam import check_iam_no_allusers_access client = _make_client() - self._setup_iam_policy(client, [ - {"role": "roles/viewer", "members": ["allUsers"]} - ]) + self._setup_iam_policy(client, [{"role": "roles/viewer", "members": ["allUsers"]}]) findings = check_iam_no_allusers_access(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -156,9 +159,9 @@ def test_allauthenticated_binding_fails(self): from shasta.gcp.iam import check_iam_no_allusers_access client = _make_client() - self._setup_iam_policy(client, [ - {"role": "roles/viewer", "members": ["allAuthenticatedUsers"]} - ]) + self._setup_iam_policy( + client, [{"role": "roles/viewer", "members": ["allAuthenticatedUsers"]}] + ) findings = check_iam_no_allusers_access(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -167,9 +170,9 @@ def test_no_allusers_passes(self): from shasta.gcp.iam import check_iam_no_allusers_access client = _make_client() - self._setup_iam_policy(client, [ - {"role": "roles/viewer", "members": ["user:alice@example.com"]} - ]) + self._setup_iam_policy( + client, [{"role": "roles/viewer", "members": ["user:alice@example.com"]}] + ) findings = check_iam_no_allusers_access(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -187,12 +190,15 @@ def test_user_holder_at_project_scope_fails(self): from shasta.gcp.iam import check_iam_service_account_token_creator client = _make_client() - self._setup_iam_policy(client, [ - { - "role": "roles/iam.serviceAccountTokenCreator", - "members": ["user:attacker@example.com"], - } - ]) + self._setup_iam_policy( + client, + [ + { + "role": "roles/iam.serviceAccountTokenCreator", + "members": ["user:attacker@example.com"], + } + ], + ) findings = check_iam_service_account_token_creator(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -202,12 +208,15 @@ def test_sa_holder_at_project_scope_fails(self): from shasta.gcp.iam import check_iam_service_account_token_creator client = _make_client() - self._setup_iam_policy(client, [ - { - "role": "roles/iam.serviceAccountTokenCreator", - "members": ["serviceAccount:bad-sa@test-project.iam.gserviceaccount.com"], - } - ]) + self._setup_iam_policy( + client, + [ + { + "role": "roles/iam.serviceAccountTokenCreator", + "members": ["serviceAccount:bad-sa@test-project.iam.gserviceaccount.com"], + } + ], + ) findings = check_iam_service_account_token_creator(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings, "SA holder at project scope should FAIL — same blast radius as user" @@ -216,9 +225,9 @@ def test_no_impersonation_roles_passes(self): from shasta.gcp.iam import check_iam_service_account_token_creator client = _make_client() - self._setup_iam_policy(client, [ - {"role": "roles/storage.objectViewer", "members": ["user:alice@example.com"]} - ]) + self._setup_iam_policy( + client, [{"role": "roles/storage.objectViewer", "members": ["user:alice@example.com"]}] + ) findings = check_iam_service_account_token_creator(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -227,12 +236,15 @@ def test_service_account_user_role_also_fails(self): from shasta.gcp.iam import check_iam_service_account_token_creator client = _make_client() - self._setup_iam_policy(client, [ - { - "role": "roles/iam.serviceAccountUser", - "members": ["group:devs@example.com"], - } - ]) + self._setup_iam_policy( + client, + [ + { + "role": "roles/iam.serviceAccountUser", + "members": ["group:devs@example.com"], + } + ], + ) findings = check_iam_service_account_token_creator(client, PROJECT_ID, "global") fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -262,13 +274,13 @@ def _setup_iam(self, client, accounts, keys_per_account=None): } keys_per_account = keys_per_account or {} - def _keys_side_effect(name, keyTypes): + def _keys_side_effect(name, keyTypes): # noqa: N803 (mirrors GCP IAM API kwarg name) return MagicMock( execute=MagicMock(return_value={"keys": keys_per_account.get(name, [])}) ) iam.projects.return_value.serviceAccounts.return_value.keys.return_value.list.side_effect = ( - lambda name, keyTypes: MagicMock( + lambda name, keyTypes: MagicMock( # noqa: N803 (mirrors GCP IAM API kwarg name) execute=MagicMock(return_value={"keys": keys_per_account.get(name, [])}) ) ) @@ -278,9 +290,16 @@ def test_no_user_keys_passes(self): from shasta.gcp.iam import check_iam_workload_identity_preferred client = _make_client() - self._setup_iam(client, [ - {"email": "sa@test-project.iam.gserviceaccount.com", "name": "projects/test-project/serviceAccounts/sa"} - ], {}) # No user-managed keys → PASS + self._setup_iam( + client, + [ + { + "email": "sa@test-project.iam.gserviceaccount.com", + "name": "projects/test-project/serviceAccounts/sa", + } + ], + {}, + ) # No user-managed keys → PASS findings = check_iam_workload_identity_preferred(client, PROJECT_ID, "global") pass_findings = [f for f in findings if f.status == ComplianceStatus.PASS] assert pass_findings @@ -344,15 +363,18 @@ def test_open_ssh_fails(self): from shasta.gcp.networking import check_firewall_no_unrestricted_ssh client = _make_client() - self._setup_firewall(client, [ - { - "name": "allow-ssh", - "direction": "INGRESS", - "disabled": False, - "sourceRanges": ["0.0.0.0/0"], - "allowed": [{"IPProtocol": "tcp", "ports": ["22"]}], - } - ]) + self._setup_firewall( + client, + [ + { + "name": "allow-ssh", + "direction": "INGRESS", + "disabled": False, + "sourceRanges": ["0.0.0.0/0"], + "allowed": [{"IPProtocol": "tcp", "ports": ["22"]}], + } + ], + ) findings = check_firewall_no_unrestricted_ssh(client, PROJECT_ID) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -361,15 +383,18 @@ def test_restricted_ssh_passes(self): from shasta.gcp.networking import check_firewall_no_unrestricted_ssh client = _make_client() - self._setup_firewall(client, [ - { - "name": "allow-ssh-corp", - "direction": "INGRESS", - "disabled": False, - "sourceRanges": ["10.0.0.0/8"], - "allowed": [{"IPProtocol": "tcp", "ports": ["22"]}], - } - ]) + self._setup_firewall( + client, + [ + { + "name": "allow-ssh-corp", + "direction": "INGRESS", + "disabled": False, + "sourceRanges": ["10.0.0.0/8"], + "allowed": [{"IPProtocol": "tcp", "ports": ["22"]}], + } + ], + ) findings = check_firewall_no_unrestricted_ssh(client, PROJECT_ID) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -389,15 +414,18 @@ def test_disabled_rule_passes(self): from shasta.gcp.networking import check_firewall_no_unrestricted_ssh client = _make_client() - self._setup_firewall(client, [ - { - "name": "allow-ssh-disabled", - "direction": "INGRESS", - "disabled": True, - "sourceRanges": ["0.0.0.0/0"], - "allowed": [{"IPProtocol": "tcp", "ports": ["22"]}], - } - ]) + self._setup_firewall( + client, + [ + { + "name": "allow-ssh-disabled", + "direction": "INGRESS", + "disabled": True, + "sourceRanges": ["0.0.0.0/0"], + "allowed": [{"IPProtocol": "tcp", "ports": ["22"]}], + } + ], + ) findings = check_firewall_no_unrestricted_ssh(client, PROJECT_ID) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -450,13 +478,16 @@ def test_subnet_without_flow_logs_fails(self): from shasta.gcp.networking import check_subnet_flow_logs_enabled client = _make_client() - self._setup(client, [ - { - "name": "default", - "selfLink": "https://compute.googleapis.com/compute/v1/projects/test-project/regions/us-central1/subnetworks/default", - # No logConfig → flow logs disabled - } - ]) + self._setup( + client, + [ + { + "name": "default", + "selfLink": "https://compute.googleapis.com/compute/v1/projects/test-project/regions/us-central1/subnetworks/default", + # No logConfig → flow logs disabled + } + ], + ) findings = check_subnet_flow_logs_enabled(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -467,13 +498,16 @@ def test_subnet_with_flow_logs_passes(self): from shasta.gcp.networking import check_subnet_flow_logs_enabled client = _make_client() - self._setup(client, [ - { - "name": "my-subnet", - "selfLink": "https://compute.googleapis.com/compute/v1/projects/test-project/regions/us-central1/subnetworks/my-subnet", - "logConfig": {"enable": True}, - } - ]) + self._setup( + client, + [ + { + "name": "my-subnet", + "selfLink": "https://compute.googleapis.com/compute/v1/projects/test-project/regions/us-central1/subnetworks/my-subnet", + "logConfig": {"enable": True}, + } + ], + ) findings = check_subnet_flow_logs_enabled(client, PROJECT_ID, REGION) assert all(f.status == ComplianceStatus.PASS for f in findings) @@ -482,18 +516,21 @@ def test_mixed_subnets_emit_per_subnet_findings(self): from shasta.gcp.networking import check_subnet_flow_logs_enabled client = _make_client() - self._setup(client, [ - { - "name": "compliant-subnet", - "selfLink": "https://example.com/subnetworks/compliant-subnet", - "logConfig": {"enable": True}, - }, - { - "name": "bad-subnet", - "selfLink": "https://example.com/subnetworks/bad-subnet", - # no logConfig - }, - ]) + self._setup( + client, + [ + { + "name": "compliant-subnet", + "selfLink": "https://example.com/subnetworks/compliant-subnet", + "logConfig": {"enable": True}, + }, + { + "name": "bad-subnet", + "selfLink": "https://example.com/subnetworks/bad-subnet", + # no logConfig + }, + ], + ) findings = check_subnet_flow_logs_enabled(client, PROJECT_ID, REGION) statuses = {f.status for f in findings} assert ComplianceStatus.PASS in statuses @@ -624,9 +661,7 @@ def _setup_kms(client, key_rings, crypto_keys=None): kms = MagicMock() loc_chain = kms.projects.return_value.locations.return_value # Step 1: enumerate locations - loc_chain.list.return_value.execute.return_value = { - "locations": [{"locationId": "global"}] - } + loc_chain.list.return_value.execute.return_value = {"locations": [{"locationId": "global"}]} ring_chain = loc_chain.keyRings.return_value # Step 2: list key rings per location ring_chain.list.return_value.execute.return_value = {"keyRings": key_rings} @@ -643,15 +678,17 @@ def test_key_without_rotation_fails(self): from shasta.gcp.encryption import check_kms_key_rotation_period client = _make_client() - _setup_kms(client, [ - {"name": "projects/test-project/locations/global/keyRings/my-ring"} - ], [ - { - "name": "projects/test-project/locations/global/keyRings/my-ring/cryptoKeys/my-key", - "purpose": "ENCRYPT_DECRYPT", - # No rotationPeriod → non-compliant - } - ]) + _setup_kms( + client, + [{"name": "projects/test-project/locations/global/keyRings/my-ring"}], + [ + { + "name": "projects/test-project/locations/global/keyRings/my-ring/cryptoKeys/my-key", + "purpose": "ENCRYPT_DECRYPT", + # No rotationPeriod → non-compliant + } + ], + ) findings = check_kms_key_rotation_period(client, PROJECT_ID) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] @@ -661,15 +698,17 @@ def test_key_with_90day_rotation_passes(self): from shasta.gcp.encryption import check_kms_key_rotation_period client = _make_client() - _setup_kms(client, [ - {"name": "projects/test-project/locations/global/keyRings/my-ring"} - ], [ - { - "name": "projects/test-project/locations/global/keyRings/my-ring/cryptoKeys/my-key", - "purpose": "ENCRYPT_DECRYPT", - "rotationPeriod": "7776000s", # 90 days exactly - } - ]) + _setup_kms( + client, + [{"name": "projects/test-project/locations/global/keyRings/my-ring"}], + [ + { + "name": "projects/test-project/locations/global/keyRings/my-ring/cryptoKeys/my-key", + "purpose": "ENCRYPT_DECRYPT", + "rotationPeriod": "7776000s", # 90 days exactly + } + ], + ) findings = check_kms_key_rotation_period(client, PROJECT_ID) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] @@ -679,15 +718,17 @@ def test_key_with_too_long_rotation_fails(self): from shasta.gcp.encryption import check_kms_key_rotation_period client = _make_client() - _setup_kms(client, [ - {"name": "projects/test-project/locations/global/keyRings/my-ring"} - ], [ - { - "name": "projects/test-project/locations/global/keyRings/my-ring/cryptoKeys/my-key", - "purpose": "ENCRYPT_DECRYPT", - "rotationPeriod": "31536000s", # 365 days — too long - } - ]) + _setup_kms( + client, + [{"name": "projects/test-project/locations/global/keyRings/my-ring"}], + [ + { + "name": "projects/test-project/locations/global/keyRings/my-ring/cryptoKeys/my-key", + "purpose": "ENCRYPT_DECRYPT", + "rotationPeriod": "31536000s", # 365 days — too long + } + ], + ) findings = check_kms_key_rotation_period(client, PROJECT_ID) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] @@ -708,8 +749,8 @@ def test_location_list_api_error_not_assessed(self): client = _make_client() kms = MagicMock() - kms.projects.return_value.locations.return_value.list.return_value.execute.side_effect = Exception( - "403 Permission denied" + kms.projects.return_value.locations.return_value.list.return_value.execute.side_effect = ( + Exception("403 Permission denied") ) client.service.return_value = kms @@ -831,9 +872,7 @@ def test_no_os_login_fails(self): compute = MagicMock() compute.projects.return_value.get.return_value.execute.return_value = { "name": PROJECT_ID, - "commonInstanceMetadata": { - "items": [{"key": "enable-oslogin", "value": "FALSE"}] - }, + "commonInstanceMetadata": {"items": [{"key": "enable-oslogin", "value": "FALSE"}]}, } client.service.return_value = compute findings = check_os_login_project_enabled(client, PROJECT_ID) @@ -847,9 +886,7 @@ def test_os_login_enabled_passes(self): compute = MagicMock() compute.projects.return_value.get.return_value.execute.return_value = { "name": PROJECT_ID, - "commonInstanceMetadata": { - "items": [{"key": "enable-oslogin", "value": "TRUE"}] - }, + "commonInstanceMetadata": {"items": [{"key": "enable-oslogin", "value": "TRUE"}]}, } client.service.return_value = compute findings = check_os_login_project_enabled(client, PROJECT_ID) @@ -879,9 +916,7 @@ def test_serial_port_enabled_fails(self): compute = MagicMock() compute.projects.return_value.get.return_value.execute.return_value = { "name": PROJECT_ID, - "commonInstanceMetadata": { - "items": [{"key": "serial-port-enable", "value": "1"}] - }, + "commonInstanceMetadata": {"items": [{"key": "serial-port-enable", "value": "1"}]}, } client.service.return_value = compute findings = check_serial_port_disabled_project(client, PROJECT_ID) @@ -895,9 +930,7 @@ def test_serial_port_disabled_passes(self): compute = MagicMock() compute.projects.return_value.get.return_value.execute.return_value = { "name": PROJECT_ID, - "commonInstanceMetadata": { - "items": [{"key": "serial-port-enable", "value": "false"}] - }, + "commonInstanceMetadata": {"items": [{"key": "serial-port-enable", "value": "false"}]}, } client.service.return_value = compute findings = check_serial_port_disabled_project(client, PROJECT_ID) @@ -1009,9 +1042,7 @@ def test_storage_findings_have_compliance_controls(self): mock_bucket = MagicMock() mock_bucket.name = "public-bucket" mock_policy = MagicMock() - mock_policy.bindings = [ - {"role": "roles/storage.objectViewer", "members": ["allUsers"]} - ] + mock_policy.bindings = [{"role": "roles/storage.objectViewer", "members": ["allUsers"]}] mock_bucket.get_iam_policy.return_value = mock_policy storage_client = MagicMock() @@ -1125,9 +1156,7 @@ def test_allusers_invoker_fails(self): # Override getIamPolicy to return allUsers binding run.projects.return_value.locations.return_value.services.return_value.getIamPolicy.return_value.execute.return_value = { - "bindings": [ - {"role": "roles/run.invoker", "members": ["allUsers"]} - ] + "bindings": [{"role": "roles/run.invoker", "members": ["allUsers"]}] } findings = check_cloud_run_no_unauthenticated_access(client, PROJECT_ID, REGION) @@ -1143,7 +1172,10 @@ def test_authenticated_only_passes(self): run = _setup_cloud_run_services(client, [{"name": svc_name}]) run.projects.return_value.locations.return_value.services.return_value.getIamPolicy.return_value.execute.return_value = { "bindings": [ - {"role": "roles/run.invoker", "members": ["serviceAccount:caller@proj.iam.gserviceaccount.com"]} + { + "role": "roles/run.invoker", + "members": ["serviceAccount:caller@proj.iam.gserviceaccount.com"], + } ] } @@ -1179,14 +1211,17 @@ def test_default_compute_sa_fails(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - { - "name": svc_name, - "template": { - "serviceAccount": "123456789-compute@developer.gserviceaccount.com" - }, - } - ]) + _setup_cloud_run_services( + client, + [ + { + "name": svc_name, + "template": { + "serviceAccount": "123456789-compute@developer.gserviceaccount.com" + }, + } + ], + ) findings = check_cloud_run_no_default_service_account(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -1196,14 +1231,17 @@ def test_dedicated_sa_passes(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - { - "name": svc_name, - "template": { - "serviceAccount": f"my-svc-sa@{PROJECT_ID}.iam.gserviceaccount.com" - }, - } - ]) + _setup_cloud_run_services( + client, + [ + { + "name": svc_name, + "template": { + "serviceAccount": f"my-svc-sa@{PROJECT_ID}.iam.gserviceaccount.com" + }, + } + ], + ) findings = check_cloud_run_no_default_service_account(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -1213,9 +1251,12 @@ def test_no_sa_specified_fails(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - {"name": svc_name, "template": {}} # No serviceAccount key - ]) + _setup_cloud_run_services( + client, + [ + {"name": svc_name, "template": {}} # No serviceAccount key + ], + ) findings = check_cloud_run_no_default_service_account(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -1227,9 +1268,7 @@ def test_all_traffic_ingress_fails(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - {"name": svc_name, "ingress": "INGRESS_TRAFFIC_ALL"} - ]) + _setup_cloud_run_services(client, [{"name": svc_name, "ingress": "INGRESS_TRAFFIC_ALL"}]) findings = check_cloud_run_ingress_restricted(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -1239,9 +1278,9 @@ def test_internal_only_passes(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - {"name": svc_name, "ingress": "INGRESS_TRAFFIC_INTERNAL_ONLY"} - ]) + _setup_cloud_run_services( + client, [{"name": svc_name, "ingress": "INGRESS_TRAFFIC_INTERNAL_ONLY"}] + ) findings = check_cloud_run_ingress_restricted(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -1251,9 +1290,9 @@ def test_lb_only_passes(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - {"name": svc_name, "ingress": "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"} - ]) + _setup_cloud_run_services( + client, [{"name": svc_name, "ingress": "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"}] + ) findings = check_cloud_run_ingress_restricted(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -1265,9 +1304,12 @@ def test_no_binauthz_fails(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - {"name": svc_name} # No binaryAuthorization key - ]) + _setup_cloud_run_services( + client, + [ + {"name": svc_name} # No binaryAuthorization key + ], + ) findings = check_cloud_run_binary_authorization(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -1277,9 +1319,9 @@ def test_use_default_passes(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - {"name": svc_name, "binaryAuthorization": {"useDefault": True}} - ]) + _setup_cloud_run_services( + client, [{"name": svc_name, "binaryAuthorization": {"useDefault": True}}] + ) findings = check_cloud_run_binary_authorization(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -1289,12 +1331,17 @@ def test_custom_policy_passes(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - { - "name": svc_name, - "binaryAuthorization": {"policy": f"projects/{PROJECT_ID}/platforms/cloudRun/policies/default"} - } - ]) + _setup_cloud_run_services( + client, + [ + { + "name": svc_name, + "binaryAuthorization": { + "policy": f"projects/{PROJECT_ID}/platforms/cloudRun/policies/default" + }, + } + ], + ) findings = check_cloud_run_binary_authorization(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -1306,21 +1353,24 @@ def test_plaintext_api_key_fails(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - { - "name": svc_name, - "template": { - "containers": [ - { - "image": "gcr.io/project/image:latest", - "env": [ - {"name": "API_KEY", "value": "super-secret-value"}, - ], - } - ] - }, - } - ]) + _setup_cloud_run_services( + client, + [ + { + "name": svc_name, + "template": { + "containers": [ + { + "image": "gcr.io/project/image:latest", + "env": [ + {"name": "API_KEY", "value": "super-secret-value"}, + ], + } + ] + }, + } + ], + ) findings = check_cloud_run_no_plaintext_secrets(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings @@ -1330,26 +1380,32 @@ def test_secret_manager_ref_passes(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - { - "name": svc_name, - "template": { - "containers": [ - { - "image": "gcr.io/project/image:latest", - "env": [ - { - "name": "API_KEY", - "valueSource": { - "secretKeyRef": {"secret": "my-api-key", "version": "latest"} + _setup_cloud_run_services( + client, + [ + { + "name": svc_name, + "template": { + "containers": [ + { + "image": "gcr.io/project/image:latest", + "env": [ + { + "name": "API_KEY", + "valueSource": { + "secretKeyRef": { + "secret": "my-api-key", + "version": "latest", + } + }, }, - }, - ], - } - ] - }, - } - ]) + ], + } + ] + }, + } + ], + ) findings = check_cloud_run_no_plaintext_secrets(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -1359,22 +1415,25 @@ def test_non_secret_env_var_passes(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - { - "name": svc_name, - "template": { - "containers": [ - { - "image": "gcr.io/project/image:latest", - "env": [ - {"name": "LOG_LEVEL", "value": "INFO"}, - {"name": "PORT", "value": "8080"}, - ], - } - ] - }, - } - ]) + _setup_cloud_run_services( + client, + [ + { + "name": svc_name, + "template": { + "containers": [ + { + "image": "gcr.io/project/image:latest", + "env": [ + {"name": "LOG_LEVEL", "value": "INFO"}, + {"name": "PORT", "value": "8080"}, + ], + } + ] + }, + } + ], + ) findings = check_cloud_run_no_plaintext_secrets(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert not fail_findings @@ -1384,21 +1443,24 @@ def test_password_env_var_fails(self): client = _make_client() svc_name = f"projects/{PROJECT_ID}/locations/{REGION}/services/my-svc" - _setup_cloud_run_services(client, [ - { - "name": svc_name, - "template": { - "containers": [ - { - "image": "gcr.io/project/image:latest", - "env": [ - {"name": "DB_PASSWORD", "value": "p@ssw0rd"}, - ], - } - ] - }, - } - ]) + _setup_cloud_run_services( + client, + [ + { + "name": svc_name, + "template": { + "containers": [ + { + "image": "gcr.io/project/image:latest", + "env": [ + {"name": "DB_PASSWORD", "value": "p@ssw0rd"}, + ], + } + ] + }, + } + ], + ) findings = check_cloud_run_no_plaintext_secrets(client, PROJECT_ID, REGION) fail_findings = [f for f in findings if f.status == ComplianceStatus.FAIL] assert fail_findings diff --git a/tests/test_gcp/test_smoke.py b/tests/test_gcp/test_smoke.py index cf55d7c..aa6e040 100644 --- a/tests/test_gcp/test_smoke.py +++ b/tests/test_gcp/test_smoke.py @@ -22,7 +22,6 @@ Severity, ) - GCP_MODULES = [ "shasta.gcp.iam", "shasta.gcp.networking", diff --git a/tests/test_integrity/test_doc_claims.py b/tests/test_integrity/test_doc_claims.py index 756114c..c22f9e5 100644 --- a/tests/test_integrity/test_doc_claims.py +++ b/tests/test_integrity/test_doc_claims.py @@ -28,7 +28,6 @@ import sys from pathlib import Path - REPO_ROOT = Path(__file__).resolve().parents[2] @@ -85,8 +84,7 @@ def aws_terraform_template_count() -> int: from shasta.remediation.engine import TERRAFORM_TEMPLATES return sum( - 1 for k in TERRAFORM_TEMPLATES - if not k.startswith("azure-") and not k.startswith("gcp-") + 1 for k in TERRAFORM_TEMPLATES if not k.startswith("azure-") and not k.startswith("gcp-") ) @@ -152,9 +150,7 @@ def whitney_collected_count() -> int: ) m = re.search(r"(\d+)\s+tests?\s+collected", result.stdout) if not m: - raise RuntimeError( - f"Could not parse Whitney test collection:\n{result.stdout[-500:]}" - ) + raise RuntimeError(f"Could not parse Whitney test collection:\n{result.stdout[-500:]}") return int(m.group(1)) @@ -256,9 +252,7 @@ def test_readme_intro_total_check_count() -> None: has used the same sentence shape: 'X compliance frameworks, Y automated checks'. """ text = README.read_text(encoding="utf-8") - pattern = re.compile( - r"(\d+)\s+automated checks(?!\s+across AWS, Azure, and GCP)" - ) + pattern = re.compile(r"(\d+)\s+automated checks(?!\s+across AWS, Azure, and GCP)") for lineno, line in enumerate(text.splitlines(), start=1): if "~~" in line: continue diff --git a/tests/test_reports/test_report_generation.py b/tests/test_reports/test_report_generation.py index d8c03d1..0640eab 100644 --- a/tests/test_reports/test_report_generation.py +++ b/tests/test_reports/test_report_generation.py @@ -24,9 +24,8 @@ save_html_report, save_markdown_report, ) -from shasta.reports.iso27001_report import save_iso27001_markdown_report from shasta.reports.hipaa_report import save_hipaa_report - +from shasta.reports.iso27001_report import save_iso27001_markdown_report # --------------------------------------------------------------------------- # Fixtures diff --git a/tests/test_trustcenter/test_generator.py b/tests/test_trustcenter/test_generator.py index f06993d..ed2cc02 100644 --- a/tests/test_trustcenter/test_generator.py +++ b/tests/test_trustcenter/test_generator.py @@ -7,11 +7,8 @@ from __future__ import annotations -from datetime import UTC, datetime from pathlib import Path -import pytest - from shasta.evidence.models import ( CheckDomain, CloudProvider, diff --git a/tests/test_whitney/test_ai_policies.py b/tests/test_whitney/test_ai_policies.py index 2777805..14d3a7d 100644 --- a/tests/test_whitney/test_ai_policies.py +++ b/tests/test_whitney/test_ai_policies.py @@ -8,14 +8,22 @@ from shasta.policies.ai_policies import ( POLICIES, - generate_policy, generate_all_policies, + generate_policy, list_policies, ) - # All controls/obligations that require policy documents -REQUIRES_POLICY_IDS = {"AI-5.2", "AI-6.1", "AI-8.2", "AI-A.2", "EUAI-9", "EUAI-11", "EUAI-14", "EUAI-52"} +REQUIRES_POLICY_IDS = { + "AI-5.2", + "AI-6.1", + "AI-8.2", + "AI-A.2", + "EUAI-9", + "EUAI-11", + "EUAI-14", + "EUAI-52", +} class TestPoliciesDict: diff --git a/tests/test_whitney/test_ai_sbom.py b/tests/test_whitney/test_ai_sbom.py index b642cd3..71884e8 100644 --- a/tests/test_whitney/test_ai_sbom.py +++ b/tests/test_whitney/test_ai_sbom.py @@ -4,25 +4,20 @@ from unittest.mock import patch -import pytest - from shasta.aws.ai_sbom import ( AIComponent, AIComponentType, - AISBOMReport, - KNOWN_AI_PACKAGES, + _infer_model_provider, + _make_purl, check_ai_component_vulnerabilities, generate_ai_sbom, scan_ai_sbom_code_only, scan_aws_for_ai_components, scan_azure_for_ai_components, scan_code_for_ai_components, - _infer_model_provider, - _make_purl, ) from tests.test_whitney.conftest import write_file - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -134,7 +129,9 @@ def test_detects_generic_model(self, tmp_path): assert models[0].provider == "openai" def test_detects_pinned_model(self, tmp_path): - write_file(tmp_path, "app.py", 'response = client.chat(model="gpt-4-20240125", messages=[])\n') + write_file( + tmp_path, "app.py", 'response = client.chat(model="gpt-4-20240125", messages=[])\n' + ) components = scan_code_for_ai_components(tmp_path) models = [c for c in components if c.component_type == AIComponentType.MODEL] assert len(models) == 1 @@ -362,9 +359,7 @@ class TestScanAzureComponents: @patch("shasta.azure.ai_discovery.discover_azure_ai_services") def test_openai_deployments(self, mock_discover): mock_discover.return_value = { - "azure_openai": { - "deployments": [{"name": "gpt4-deployment", "model": "gpt-4"}] - }, + "azure_openai": {"deployments": [{"name": "gpt4-deployment", "model": "gpt-4"}]}, "azure_ml": {"workspaces": []}, "cognitive_services": {"services": []}, } diff --git a/tests/test_whitney/test_eu_ai_act.py b/tests/test_whitney/test_eu_ai_act.py index a4aef95..8f52d25 100644 --- a/tests/test_whitney/test_eu_ai_act.py +++ b/tests/test_whitney/test_eu_ai_act.py @@ -3,9 +3,9 @@ from shasta.compliance.ai.eu_ai_act import ( EU_AI_ACT_OBLIGATIONS, EUAIActObligation, + get_automated_eu_ai_act_obligations, get_eu_ai_act_obligation, get_eu_ai_act_obligations_for_check, - get_automated_eu_ai_act_obligations, get_policy_required_eu_ai_act_obligations, ) diff --git a/tests/test_whitney/test_integrity.py b/tests/test_whitney/test_integrity.py index 5e388e7..b715174 100644 --- a/tests/test_whitney/test_integrity.py +++ b/tests/test_whitney/test_integrity.py @@ -17,7 +17,6 @@ import pytest - # --------------------------------------------------------------------------- # Module existence and non-emptiness # --------------------------------------------------------------------------- @@ -64,8 +63,7 @@ def test_module_imports_and_has_content(self, module_path): public_members = [ name for name, obj in inspect.getmembers(mod) - if not name.startswith("_") - and not inspect.ismodule(obj) + if not name.startswith("_") and not inspect.ismodule(obj) ] assert len(public_members) >= 1, ( f"{module_path} imports but has no public members — is it a stub?" @@ -88,15 +86,11 @@ class TestNoEmptyStubDirectories: def test_subdir_has_real_code(self, rel_dir): d = Path(rel_dir) py_files = [f for f in d.glob("*.py") if f.name != "__init__.py"] - assert len(py_files) >= 1, ( - f"{rel_dir}/ has no .py files besides __init__.py — empty stub" - ) + assert len(py_files) >= 1, f"{rel_dir}/ has no .py files besides __init__.py — empty stub" for py_file in py_files: content = py_file.read_text(encoding="utf-8") has_code = "def " in content or "class " in content or " = " in content - assert has_code, ( - f"{py_file} has no functions, classes, or data — empty stub" - ) + assert has_code, f"{py_file} has no functions, classes, or data — empty stub" # --------------------------------------------------------------------------- @@ -153,13 +147,11 @@ def test_policies_dict_has_7(self): assert len(POLICIES) == 7 def test_all_policies_render(self): - from shasta.policies.ai_policies import generate_policy, POLICIES + from shasta.policies.ai_policies import POLICIES, generate_policy for policy_id in POLICIES: result = generate_policy(policy_id, company_name="IntegrityTest") - assert "IntegrityTest" in result, ( - f"Policy {policy_id} didn't render with company name" - ) + assert "IntegrityTest" in result, f"Policy {policy_id} didn't render with company name" assert len(result) > 100, ( f"Policy {policy_id} rendered but is suspiciously short ({len(result)} chars)" ) @@ -169,8 +161,8 @@ class TestSBOMProducesOutput: """AI SBOM must produce valid CycloneDX output, not empty stubs.""" def test_code_scan_produces_cyclonedx(self, tmp_path): - from tests.test_whitney.conftest import write_file from shasta.aws.ai_sbom import scan_ai_sbom_code_only + from tests.test_whitney.conftest import write_file write_file(tmp_path, "requirements.txt", "openai==1.3.0\n") result = scan_ai_sbom_code_only(tmp_path) @@ -224,14 +216,11 @@ class TestMapperEnrichesAllFrameworks: """The mapper must add all 6 framework cross-references to findings.""" def test_enrichment_adds_all_framework_keys(self): + from shasta.compliance.ai.mapper import enrich_findings_with_ai_controls from shasta.evidence.models import ComplianceStatus - from tests.test_whitney.conftest import _make_finding - from shasta.compliance.ai.mapper import enrich_findings_with_ai_controls - findings = [ - _make_finding("code-prompt-injection-risk", ComplianceStatus.FAIL) - ] + findings = [_make_finding("code-prompt-injection-risk", ComplianceStatus.FAIL)] enrich_findings_with_ai_controls(findings) details = findings[0].details diff --git a/tests/test_whitney/test_iso42001.py b/tests/test_whitney/test_iso42001.py index d8f9fb5..43c222d 100644 --- a/tests/test_whitney/test_iso42001.py +++ b/tests/test_whitney/test_iso42001.py @@ -3,9 +3,9 @@ from shasta.compliance.ai.iso42001 import ( ISO42001_CONTROLS, ISO42001Control, + get_automated_iso42001_controls, get_iso42001_control, get_iso42001_controls_for_check, - get_automated_iso42001_controls, get_policy_required_iso42001_controls, ) diff --git a/tests/test_whitney/test_mapper.py b/tests/test_whitney/test_mapper.py index 65dbd8b..2a94afd 100644 --- a/tests/test_whitney/test_mapper.py +++ b/tests/test_whitney/test_mapper.py @@ -1,14 +1,13 @@ """Tests for Whitney finding-to-control mapping and enrichment.""" -from shasta.evidence.models import ComplianceStatus, Severity - +from shasta.compliance.ai.eu_ai_act import EU_AI_ACT_OBLIGATIONS +from shasta.compliance.ai.iso42001 import ISO42001_CONTROLS from shasta.compliance.ai.mapper import ( enrich_findings_with_ai_controls, - get_iso42001_control_summary, get_eu_ai_act_obligation_summary, + get_iso42001_control_summary, ) -from shasta.compliance.ai.iso42001 import ISO42001_CONTROLS -from shasta.compliance.ai.eu_ai_act import EU_AI_ACT_OBLIGATIONS +from shasta.evidence.models import ComplianceStatus from tests.test_whitney.conftest import _make_finding diff --git a/tests/test_whitney/test_mitre_atlas.py b/tests/test_whitney/test_mitre_atlas.py index 8fb2c79..b90bced 100644 --- a/tests/test_whitney/test_mitre_atlas.py +++ b/tests/test_whitney/test_mitre_atlas.py @@ -3,11 +3,11 @@ from shasta.compliance.ai.mitre_atlas import ( ATLAS_TECHNIQUES, ATLASTechnique, + get_atlas_tactics, get_atlas_technique, + get_atlas_techniques_by_tactic, get_atlas_techniques_for_check, get_automated_atlas_techniques, - get_atlas_techniques_by_tactic, - get_atlas_tactics, ) diff --git a/tests/test_whitney/test_nist_ai_600_1.py b/tests/test_whitney/test_nist_ai_600_1.py index 656f630..0f9aa38 100644 --- a/tests/test_whitney/test_nist_ai_600_1.py +++ b/tests/test_whitney/test_nist_ai_600_1.py @@ -25,9 +25,7 @@ def test_all_risks_have_required_fields(self): def test_all_risks_have_rmf_crosswalk(self): """Every 600-1 risk should reference parent RMF categories.""" for risk_id, risk in NIST_AI_600_1_RISKS.items(): - assert risk.nist_rmf_crosswalk, ( - f"{risk_id} has no NIST RMF crosswalk references" - ) + assert risk.nist_rmf_crosswalk, f"{risk_id} has no NIST RMF crosswalk references" class TestGetNISTAI6001Risk: diff --git a/tests/test_whitney/test_nist_ai_rmf.py b/tests/test_whitney/test_nist_ai_rmf.py index c3cac5f..3e4c81a 100644 --- a/tests/test_whitney/test_nist_ai_rmf.py +++ b/tests/test_whitney/test_nist_ai_rmf.py @@ -3,11 +3,11 @@ from shasta.compliance.ai.nist_ai_rmf import ( NIST_AI_RMF_CATEGORIES, NISTAIRMFCategory, - get_nist_ai_rmf_category, - get_nist_ai_rmf_categories_for_check, get_automated_nist_ai_rmf_categories, - get_policy_required_nist_ai_rmf_categories, get_nist_ai_rmf_categories_by_function, + get_nist_ai_rmf_categories_for_check, + get_nist_ai_rmf_category, + get_policy_required_nist_ai_rmf_categories, ) diff --git a/tests/test_whitney/test_owasp_agentic.py b/tests/test_whitney/test_owasp_agentic.py index 50d9640..2665be8 100644 --- a/tests/test_whitney/test_owasp_agentic.py +++ b/tests/test_whitney/test_owasp_agentic.py @@ -3,9 +3,9 @@ from shasta.compliance.ai.owasp_agentic import ( OWASP_AGENTIC_TOP10, OWASPAgenticRisk, + get_automated_owasp_agentic_risks, get_owasp_agentic_risk, get_owasp_agentic_risks_for_check, - get_automated_owasp_agentic_risks, ) diff --git a/tests/test_whitney/test_owasp_llm_top10.py b/tests/test_whitney/test_owasp_llm_top10.py index 785b766..203bd8d 100644 --- a/tests/test_whitney/test_owasp_llm_top10.py +++ b/tests/test_whitney/test_owasp_llm_top10.py @@ -3,9 +3,9 @@ from shasta.compliance.ai.owasp_llm_top10 import ( OWASP_LLM_TOP10, OWASPLLMRisk, + get_automated_owasp_llm_risks, get_owasp_llm_risk, get_owasp_llm_risks_for_check, - get_automated_owasp_llm_risks, ) diff --git a/tests/test_whitney/test_scorer.py b/tests/test_whitney/test_scorer.py index 41cf3fa..061ee4e 100644 --- a/tests/test_whitney/test_scorer.py +++ b/tests/test_whitney/test_scorer.py @@ -3,18 +3,14 @@ Mirrors the structure of tests/test_compliance/test_scorer.py. """ -import pytest - from shasta.compliance.ai.scorer import ( AIGovernanceScore, - calculate_ai_governance_score, _score_to_grade, + calculate_ai_governance_score, ) -from shasta.evidence.models import ComplianceStatus, Severity - +from shasta.evidence.models import ComplianceStatus from tests.test_whitney.conftest import _make_finding - # --------------------------------------------------------------------------- # Check IDs that map to ISO 42001 and/or EU AI Act controls # --------------------------------------------------------------------------- diff --git a/tests/test_workflows/test_drift.py b/tests/test_workflows/test_drift.py index 965216b..49513c0 100644 --- a/tests/test_workflows/test_drift.py +++ b/tests/test_workflows/test_drift.py @@ -1,16 +1,14 @@ """Tests for compliance drift detection.""" -import pytest - -from shasta.workflows.drift import detect_drift, DriftReport from shasta.evidence.models import ( - Finding, - ScanResult, CheckDomain, + CloudProvider, ComplianceStatus, + Finding, + ScanResult, Severity, - CloudProvider, ) +from shasta.workflows.drift import DriftReport, detect_drift def _make_finding( diff --git a/tests/test_workflows/test_risk_register.py b/tests/test_workflows/test_risk_register.py index ff5e025..fadf5a5 100644 --- a/tests/test_workflows/test_risk_register.py +++ b/tests/test_workflows/test_risk_register.py @@ -2,20 +2,19 @@ import pytest -from shasta.workflows.risk_register import ( - calculate_risk, - auto_seed_from_findings, - build_register, - RiskItem, - RiskRegister, - FINDING_TO_RISK, -) from shasta.evidence.models import ( - Finding, CheckDomain, + CloudProvider, ComplianceStatus, + Finding, Severity, - CloudProvider, +) +from shasta.workflows.risk_register import ( + FINDING_TO_RISK, + RiskItem, + auto_seed_from_findings, + build_register, + calculate_risk, ) diff --git a/tests/voice/conftest.py b/tests/voice/conftest.py index 3768cd0..84f1cd0 100644 --- a/tests/voice/conftest.py +++ b/tests/voice/conftest.py @@ -1,4 +1,5 @@ """Shared test fixtures for voice tests — seeded SQLite at tmp_path.""" + from __future__ import annotations from datetime import UTC, datetime, timedelta @@ -67,47 +68,111 @@ def seeded_db_path(tmp_path: Path) -> Path: findings = [ # 4 critical, mixed clouds and frameworks - _make_finding(id="f-001", check_id="iam-mfa-enabled", title="MFA missing on root", - severity=Severity.CRITICAL, status=ComplianceStatus.FAIL, - domain=CheckDomain.IAM, resource_id="arn:aws:iam::123:root", - soc2=["CC6.1"], iso27001=["A.5.15"], hipaa=["164.312(a)(1)"]), - _make_finding(id="f-002", check_id="s3-public-access-block", title="S3 bucket allows public access", - severity=Severity.CRITICAL, status=ComplianceStatus.FAIL, - domain=CheckDomain.STORAGE, resource_id="arn:aws:s3:::prod-data", - soc2=["CC6.1", "CC6.6"], iso27001=["A.5.10"]), - _make_finding(id="f-003", check_id="azure-sql-tls", title="SQL TLS below 1.2", - severity=Severity.CRITICAL, status=ComplianceStatus.FAIL, - domain=CheckDomain.ENCRYPTION, resource_id="/subscriptions/x/sql/y", - cloud=CloudProvider.AZURE, soc2=["CC6.7"]), - _make_finding(id="f-004", check_id="cloudtrail-enabled", title="CloudTrail disabled in audit account", - severity=Severity.CRITICAL, status=ComplianceStatus.FAIL, - domain=CheckDomain.LOGGING, resource_id="arn:aws:cloudtrail::123:trail/audit", - soc2=["CC7.1", "CC7.2"]), + _make_finding( + id="f-001", + check_id="iam-mfa-enabled", + title="MFA missing on root", + severity=Severity.CRITICAL, + status=ComplianceStatus.FAIL, + domain=CheckDomain.IAM, + resource_id="arn:aws:iam::123:root", + soc2=["CC6.1"], + iso27001=["A.5.15"], + hipaa=["164.312(a)(1)"], + ), + _make_finding( + id="f-002", + check_id="s3-public-access-block", + title="S3 bucket allows public access", + severity=Severity.CRITICAL, + status=ComplianceStatus.FAIL, + domain=CheckDomain.STORAGE, + resource_id="arn:aws:s3:::prod-data", + soc2=["CC6.1", "CC6.6"], + iso27001=["A.5.10"], + ), + _make_finding( + id="f-003", + check_id="azure-sql-tls", + title="SQL TLS below 1.2", + severity=Severity.CRITICAL, + status=ComplianceStatus.FAIL, + domain=CheckDomain.ENCRYPTION, + resource_id="/subscriptions/x/sql/y", + cloud=CloudProvider.AZURE, + soc2=["CC6.7"], + ), + _make_finding( + id="f-004", + check_id="cloudtrail-enabled", + title="CloudTrail disabled in audit account", + severity=Severity.CRITICAL, + status=ComplianceStatus.FAIL, + domain=CheckDomain.LOGGING, + resource_id="arn:aws:cloudtrail::123:trail/audit", + soc2=["CC7.1", "CC7.2"], + ), # 3 high - _make_finding(id="f-010", check_id="iam-stale-key", title="Stale IAM key >180d", - severity=Severity.HIGH, status=ComplianceStatus.FAIL, - domain=CheckDomain.IAM, resource_id="arn:aws:iam::123:user/legacy-bot", - soc2=["CC6.3"]), - _make_finding(id="f-011", check_id="sg-open-22", title="Security group open on 22", - severity=Severity.HIGH, status=ComplianceStatus.FAIL, - domain=CheckDomain.NETWORKING, resource_id="sg-0e1f2a3b", - soc2=["CC6.6"]), - _make_finding(id="f-012", check_id="lambda-perm-role", title="Lambda overly permissive role", - severity=Severity.HIGH, status=ComplianceStatus.FAIL, - domain=CheckDomain.IAM, resource_id="arn:aws:lambda::123:function/proc", - soc2=["CC6.1"]), + _make_finding( + id="f-010", + check_id="iam-stale-key", + title="Stale IAM key >180d", + severity=Severity.HIGH, + status=ComplianceStatus.FAIL, + domain=CheckDomain.IAM, + resource_id="arn:aws:iam::123:user/legacy-bot", + soc2=["CC6.3"], + ), + _make_finding( + id="f-011", + check_id="sg-open-22", + title="Security group open on 22", + severity=Severity.HIGH, + status=ComplianceStatus.FAIL, + domain=CheckDomain.NETWORKING, + resource_id="sg-0e1f2a3b", + soc2=["CC6.6"], + ), + _make_finding( + id="f-012", + check_id="lambda-perm-role", + title="Lambda overly permissive role", + severity=Severity.HIGH, + status=ComplianceStatus.FAIL, + domain=CheckDomain.IAM, + resource_id="arn:aws:lambda::123:function/proc", + soc2=["CC6.1"], + ), # 3 medium, mixed status - _make_finding(id="f-020", check_id="s3-versioning", title="S3 versioning off", - severity=Severity.MEDIUM, status=ComplianceStatus.FAIL, - domain=CheckDomain.STORAGE, resource_id="arn:aws:s3:::dev-build"), - _make_finding(id="f-021", check_id="ebs-encryption-default", title="EBS default encryption", - severity=Severity.MEDIUM, status=ComplianceStatus.PASS, - domain=CheckDomain.ENCRYPTION, resource_id="ebs-default", - soc2=["CC6.7"]), - _make_finding(id="f-022", check_id="vpc-flow-logs", title="VPC flow logs off", - severity=Severity.MEDIUM, status=ComplianceStatus.PARTIAL, - domain=CheckDomain.MONITORING, resource_id="vpc-abc", - soc2=["CC7.2"]), + _make_finding( + id="f-020", + check_id="s3-versioning", + title="S3 versioning off", + severity=Severity.MEDIUM, + status=ComplianceStatus.FAIL, + domain=CheckDomain.STORAGE, + resource_id="arn:aws:s3:::dev-build", + ), + _make_finding( + id="f-021", + check_id="ebs-encryption-default", + title="EBS default encryption", + severity=Severity.MEDIUM, + status=ComplianceStatus.PASS, + domain=CheckDomain.ENCRYPTION, + resource_id="ebs-default", + soc2=["CC6.7"], + ), + _make_finding( + id="f-022", + check_id="vpc-flow-logs", + title="VPC flow logs off", + severity=Severity.MEDIUM, + status=ComplianceStatus.PARTIAL, + domain=CheckDomain.MONITORING, + resource_id="vpc-abc", + soc2=["CC7.2"], + ), ] scan = ScanResult( @@ -115,8 +180,14 @@ def seeded_db_path(tmp_path: Path) -> Path: account_id="123456789012", region="us-east-1", cloud_provider=CloudProvider.AWS, - domains_scanned=[CheckDomain.IAM, CheckDomain.STORAGE, CheckDomain.NETWORKING, - CheckDomain.ENCRYPTION, CheckDomain.LOGGING, CheckDomain.MONITORING], + domains_scanned=[ + CheckDomain.IAM, + CheckDomain.STORAGE, + CheckDomain.NETWORKING, + CheckDomain.ENCRYPTION, + CheckDomain.LOGGING, + CheckDomain.MONITORING, + ], findings=findings, started_at=_ago(2), ) @@ -125,10 +196,18 @@ def seeded_db_path(tmp_path: Path) -> Path: # Save an older scan too so trend queries have at least 2 datapoints older_findings = [ - _make_finding(id=f"old-{f.id}", check_id=f.check_id, title=f.title, - severity=f.severity, status=f.status, domain=f.domain, - resource_id=f.resource_id, soc2=f.soc2_controls, - iso27001=f.iso27001_controls, hipaa=f.hipaa_controls) + _make_finding( + id=f"old-{f.id}", + check_id=f.check_id, + title=f.title, + severity=f.severity, + status=f.status, + domain=f.domain, + resource_id=f.resource_id, + soc2=f.soc2_controls, + iso27001=f.iso27001_controls, + hipaa=f.hipaa_controls, + ) for f in findings[:7] # fewer findings = different score ] older_scan = ScanResult( @@ -151,6 +230,7 @@ def seeded_db_path(tmp_path: Path) -> Path: def store(seeded_db_path: Path): """Convenience: a Store instance pointed at the seeded DB.""" from shasta.voice.store import Store + s = Store(db_path=seeded_db_path) yield s s.close() @@ -160,10 +240,12 @@ def store(seeded_db_path: Path): def client(seeded_db_path: Path): """FastAPI TestClient bound to the seeded DB.""" import os + os.environ.setdefault("OPENAI_API_KEY", "test-key") os.environ.setdefault("ALLOWED_ORIGINS", "http://localhost:8090") from fastapi.testclient import TestClient from shasta.voice.app import create_app + app = create_app(db_path=seeded_db_path, serve_static=False) return TestClient(app) diff --git a/tests/voice/test_cli.py b/tests/voice/test_cli.py index e96bcd5..2adaad0 100644 --- a/tests/voice/test_cli.py +++ b/tests/voice/test_cli.py @@ -6,7 +6,9 @@ def test_cli_help_runs(): result = subprocess.run( [sys.executable, "-m", "shasta.voice", "--help"], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) assert result.returncode == 0 assert "voice console" in result.stdout.lower() @@ -18,7 +20,10 @@ def test_cli_missing_api_key(monkeypatch, tmp_path): db.touch() result = subprocess.run( [sys.executable, "-m", "shasta.voice", "--db", str(db), "--no-open"], - capture_output=True, text=True, timeout=10, env={**os.environ, "OPENAI_API_KEY": ""}, + capture_output=True, + text=True, + timeout=10, + env={**os.environ, "OPENAI_API_KEY": ""}, ) assert result.returncode == 2 assert "OPENAI_API_KEY" in result.stderr @@ -29,7 +34,10 @@ def test_cli_missing_db(monkeypatch, tmp_path): missing = tmp_path / "absent.db" result = subprocess.run( [sys.executable, "-m", "shasta.voice", "--db", str(missing), "--no-open"], - capture_output=True, text=True, timeout=10, env={**os.environ, "OPENAI_API_KEY": "sk-test"}, + capture_output=True, + text=True, + timeout=10, + env={**os.environ, "OPENAI_API_KEY": "sk-test"}, ) assert result.returncode == 2 assert "No scan data" in result.stderr diff --git a/tests/voice/test_models.py b/tests/voice/test_models.py index 90d0871..eed5e1c 100644 --- a/tests/voice/test_models.py +++ b/tests/voice/test_models.py @@ -75,7 +75,18 @@ def test_compliance_score_view(): def test_multi_framework_score_view(): m = MultiFrameworkScoreView( frameworks=[ - ComplianceScoreView(framework="soc2", score_percentage=82.5, grade="B", total_controls=40, passing=30, failing=8, partial=2, not_assessed=0, total_findings=120, findings_failed=15), + ComplianceScoreView( + framework="soc2", + score_percentage=82.5, + grade="B", + total_controls=40, + passing=30, + failing=8, + partial=2, + not_assessed=0, + total_findings=120, + findings_failed=15, + ), ], not_enabled=["hipaa"], ) diff --git a/tests/voice/test_observability.py b/tests/voice/test_observability.py index f64e823..9156390 100644 --- a/tests/voice/test_observability.py +++ b/tests/voice/test_observability.py @@ -7,7 +7,12 @@ def test_log_tool_call_emits_json(caplog): configure_logging() with caplog.at_level(logging.INFO, logger="shasta.voice"): - log_tool_call(tool_name="list_findings", args={"severity": "critical"}, latency_ms=1.234, result_size=4) + log_tool_call( + tool_name="list_findings", + args={"severity": "critical"}, + latency_ms=1.234, + result_size=4, + ) payloads = [json.loads(r.message) for r in caplog.records if r.message.startswith("{")] assert any(p.get("tool_name") == "list_findings" for p in payloads) matching = [p for p in payloads if p.get("tool_name") == "list_findings"][0] diff --git a/tests/voice/test_realtime_config.py b/tests/voice/test_realtime_config.py index 07bc7d3..3cae7ee 100644 --- a/tests/voice/test_realtime_config.py +++ b/tests/voice/test_realtime_config.py @@ -21,11 +21,20 @@ def test_system_prompt_mentions_redirects(): def test_tool_schemas_cover_all_14_tools(): names = {t["name"] for t in TOOL_SCHEMAS} assert names == { - "list_findings", "get_finding", "list_top_blockers", "get_resource_findings", - "get_compliance_score", "get_multi_framework_score", "get_score_trend", + "list_findings", + "get_finding", + "list_top_blockers", + "get_resource_findings", + "get_compliance_score", + "get_multi_framework_score", + "get_score_trend", "get_control_summary", - "list_scans", "get_latest_scan", - "list_risk_items", "get_risk_item", "add_risk_item", "update_risk", + "list_scans", + "get_latest_scan", + "list_risk_items", + "get_risk_item", + "add_risk_item", + "update_risk", } diff --git a/tests/voice/test_store_reads.py b/tests/voice/test_store_reads.py index 167b482..1508ed5 100644 --- a/tests/voice/test_store_reads.py +++ b/tests/voice/test_store_reads.py @@ -1,4 +1,3 @@ - from shasta.voice.store import Store diff --git a/tests/voice/test_store_writes.py b/tests/voice/test_store_writes.py index 2c4ce7e..76bd693 100644 --- a/tests/voice/test_store_writes.py +++ b/tests/voice/test_store_writes.py @@ -1,4 +1,5 @@ """Tests for Store write methods — risk register operations.""" + from shasta.voice.store import Store @@ -30,8 +31,13 @@ def test_add_risk_item_then_list(store: Store): def test_get_risk_item_by_id(store: Store): res = store.add_risk_item( - account_id="123456789012", title="t", description="d", category="iam", - likelihood="low", impact="medium", treatment="accept", + account_id="123456789012", + title="t", + description="d", + category="iam", + likelihood="low", + impact="medium", + treatment="accept", ) r = store.get_risk_item(res.record_id) assert r is not None @@ -40,8 +46,13 @@ def test_get_risk_item_by_id(store: Store): def test_update_risk_status(store: Store): res = store.add_risk_item( - account_id="123456789012", title="t", description="d", category="iam", - likelihood="low", impact="medium", treatment="accept", + account_id="123456789012", + title="t", + description="d", + category="iam", + likelihood="low", + impact="medium", + treatment="accept", ) upd = store.update_risk(risk_id=res.record_id, status="resolved", review_notes="closed") assert upd.success is True @@ -51,8 +62,13 @@ def test_update_risk_status(store: Store): def test_update_risk_treatment(store: Store): res = store.add_risk_item( - account_id="123456789012", title="t", description="d", category="iam", - likelihood="low", impact="medium", treatment="accept", + account_id="123456789012", + title="t", + description="d", + category="iam", + likelihood="low", + impact="medium", + treatment="accept", ) upd = store.update_risk(risk_id=res.record_id, treatment="mitigate", treatment_plan="new plan") assert upd.success is True diff --git a/tests/voice/test_tool_endpoints.py b/tests/voice/test_tool_endpoints.py index e1b3ebc..144fc7f 100644 --- a/tests/voice/test_tool_endpoints.py +++ b/tests/voice/test_tool_endpoints.py @@ -42,11 +42,18 @@ def test_get_multi_framework_score_endpoint(client): def test_add_and_get_risk_endpoint(client): - add = client.post("/tools/add_risk_item", json={ - "account_id": "123456789012", - "title": "x", "description": "y", "category": "iam", - "likelihood": "low", "impact": "low", "treatment": "accept", - }) + add = client.post( + "/tools/add_risk_item", + json={ + "account_id": "123456789012", + "title": "x", + "description": "y", + "category": "iam", + "likelihood": "low", + "impact": "low", + "treatment": "accept", + }, + ) assert add.status_code == 200 rid = add.json()["record_id"] diff --git a/tests/voice/test_tools_risks.py b/tests/voice/test_tools_risks.py index c20f891..ce00a7f 100644 --- a/tests/voice/test_tools_risks.py +++ b/tests/voice/test_tools_risks.py @@ -8,9 +8,14 @@ def test_list_risk_items_empty(store: Store): def test_add_risk_item_success(store: Store): res = risks_tool.add_risk_item( - store=store, account_id="123456789012", - title="t", description="d", category="iam", - likelihood="medium", impact="high", treatment="mitigate", + store=store, + account_id="123456789012", + title="t", + description="d", + category="iam", + likelihood="medium", + impact="high", + treatment="mitigate", ) assert res["success"] is True assert res["record_id"] @@ -18,9 +23,14 @@ def test_add_risk_item_success(store: Store): def test_get_risk_item_known(store: Store): add = risks_tool.add_risk_item( - store=store, account_id="123456789012", - title="t", description="d", category="iam", - likelihood="low", impact="low", treatment="accept", + store=store, + account_id="123456789012", + title="t", + description="d", + category="iam", + likelihood="low", + impact="low", + treatment="accept", ) res = risks_tool.get_risk_item(store=store, risk_id=add["record_id"]) assert res["risk_id"] == add["record_id"] @@ -33,9 +43,14 @@ def test_get_risk_item_unknown_returns_error(store: Store): def test_update_risk(store: Store): add = risks_tool.add_risk_item( - store=store, account_id="123456789012", - title="t", description="d", category="iam", - likelihood="low", impact="low", treatment="accept", + store=store, + account_id="123456789012", + title="t", + description="d", + category="iam", + likelihood="low", + impact="low", + treatment="accept", ) upd = risks_tool.update_risk(store=store, risk_id=add["record_id"], status="resolved") assert upd["success"] is True