diff --git a/.ash/.ash.yaml b/.ash/.ash.yaml deleted file mode 100644 index 791bba4..0000000 --- a/.ash/.ash.yaml +++ /dev/null @@ -1,235 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/awslabs/automated-security-helper/refs/heads/main/automated_security_helper/schemas/AshConfig.json -project_name: sample-aiml-security-assessment -global_settings: - severity_threshold: MEDIUM - ignore_paths: - # SAM build cache — transitively scanned pydantic/bs4/botocore deps - # produce thousands of non-actionable Bandit findings in third-party code. - # CI scans only changed files, so it never touches .aws-sam/ either. - - path: "aiml-security-assessment/.aws-sam/**" - reason: "SAM build cache; contains third-party deps, not project source" - - path: "**/.aws-sam/**" - reason: "SAM build cache at any depth (deps/, build/, etc.)" - # Pre-existing static HTML sample reports; the embedded base64 SVG logos - # are flagged as high-entropy strings by detect-secrets. - - path: "sample-reports/**" - reason: "Static HTML sample outputs with embedded base64 SVG icons (upstream)" - # ASH's own output directory. - - path: ".ash/ash_output/**" - reason: "ASH output directory; scanning it creates recursion" - suppressions: - # The 4 Checkov rules below flag every assessment Lambda in the upstream - # repo for: no VPC (CKV_AWS_117), no DLQ (CKV_AWS_116), no per-function - # concurrency cap (CKV_AWS_115), and no KMS-encrypted env vars - # (CKV_AWS_173). This matches the architecture choices made for all 6 - # pre-existing Lambdas (BedrockSecurity, SagemakerSecurity, - # AgentCoreSecurity, GenerateConsolidatedReport, IAMPermissionCaching, - # CleanupBucket) and applied consistently to the new - # FinServSecurityAssessmentFunction. The assessment framework runs on an - # internal schedule with short-lived invocations and reads config from a - # non-sensitive bucket-name env var; hardening these four items for every - # Lambda in the stack is a framework-wide change that should be proposed - # in a separate PR. - - rule_id: "CKV_AWS_117" - path: "aiml-security-assessment/template.yaml" - reason: "Upstream convention: assessment Lambdas are non-VPC (applies to all 7 Lambdas)" - - rule_id: "CKV_AWS_117" - path: "aiml-security-assessment/template-multi-account.yaml" - reason: "Upstream convention: assessment Lambdas are non-VPC (applies to all 7 Lambdas)" - - rule_id: "CKV_AWS_116" - path: "aiml-security-assessment/template.yaml" - reason: "Upstream convention: assessment Lambdas have no DLQ (applies to all 7 Lambdas)" - - rule_id: "CKV_AWS_116" - path: "aiml-security-assessment/template-multi-account.yaml" - reason: "Upstream convention: assessment Lambdas have no DLQ (applies to all 7 Lambdas)" - - rule_id: "CKV_AWS_115" - path: "aiml-security-assessment/template.yaml" - reason: "Upstream convention: no per-function concurrency cap (applies to all 7 Lambdas)" - - rule_id: "CKV_AWS_115" - path: "aiml-security-assessment/template-multi-account.yaml" - reason: "Upstream convention: no per-function concurrency cap (applies to all 7 Lambdas)" - - rule_id: "CKV_AWS_173" - path: "aiml-security-assessment/template.yaml" - reason: "Upstream convention: env var is a non-sensitive bucket-name reference" - - rule_id: "CKV_AWS_173" - path: "aiml-security-assessment/template-multi-account.yaml" - reason: "Upstream convention: env var is a non-sensitive bucket-name reference" - - # cdk-nag AwsSolutions-IAM5 flags any IAM statement with Resource: "*". - # The FinServ IAM statement I added to 1-aiml-security-member-roles.yaml - # uses the same pattern as every pre-existing Sid in that policy. The - # wildcard is required for list/describe operations that are not - # ARN-scopable (e.g., ListBuckets, ListTrails, ListRules, ListPolicies). - # If upstream tightens IAM across all assessment statements, the FinServ - # Sid should be tightened in the same PR. - - rule_id: "AwsSolutions-IAM5" - path: "deployment/1-aiml-security-member-roles.yaml" - reason: "Read-only list/describe actions that cannot be ARN-scoped (upstream convention)" - - rule_id: "AwsSolutions-IAM5" - path: "deployment/aiml-security-single-account.yaml" - reason: "Read-only list/describe actions that cannot be ARN-scoped (upstream convention)" - - rule_id: "CKV_AWS_173" - path: "aiml-security-assessment/template-multi-account.yaml" - reason: "Upstream convention: env var is a bucket name reference, not sensitive (same as BR/SM/AC)" -fail_on_findings: true -ash_plugin_modules: [] -external_reports_to_include: [] -converters: - archive: - enabled: true - options: {} - jupyter: - enabled: true - options: - tool_version: '>=7.16.0,<8.0.0' - install_timeout: 300 -scanners: - bandit: - enabled: true - options: - severity_threshold: null - config_file: null - confidence_level: all - ignore_nosec: false - excluded_paths: [] - additional_formats: [] - tool_version: '>=1.7.0,<2.0.0' - install_timeout: 300 - cdk-nag: - enabled: true - options: - severity_threshold: null - nag_packs: - AwsSolutionsChecks: true - HIPAASecurityChecks: false - NIST80053R4Checks: false - NIST80053R5Checks: false - PCIDSS321Checks: false - cfn-nag: - enabled: true - options: - severity_threshold: null - checkov: - enabled: true - options: - severity_threshold: null - config_file: null - skip_path: [] - additional_formats: - - cyclonedx_json - offline: false - frameworks: - - all - skip_frameworks: [] - tool_version: null - install_timeout: 300 - detect-secrets: - enabled: true - options: - severity_threshold: null - baseline_file: null - scan_settings: - version: null - generated_at: null - plugins_used: [] - filters_used: [] - results: {} - grype: - enabled: true - options: - severity_threshold: null - config_file: null - offline: false - npm-audit: - enabled: true - options: - severity_threshold: null - offline: false - opengrep: - enabled: true - options: - severity_threshold: null - config: auto - exclude: - - '*-converted.py' - - '*_report_result.txt' - exclude_rule: [] - severity: [] - metrics: auto - offline: false - patterns: [] - version: v1.15.1 - semgrep: - enabled: true - options: - severity_threshold: null - config: auto - exclude: - - '*-converted.py' - - '*_report_result.txt' - exclude_rule: [] - severity: [] - metrics: auto - offline: false - tool_version: null - install_timeout: 300 - syft: - enabled: true - options: - severity_threshold: null - config_file: null - exclude: [] - additional_outputs: - - syft-table -reporters: - csv: - enabled: true - options: {} - cyclonedx: - enabled: true - options: {} - html: - enabled: true - options: {} - flat-json: - enabled: true - options: - include_scanner_metrics: true - include_summary_metrics: true - include_metadata: true - gitlab-sast: - enabled: true - options: {} - junitxml: - enabled: true - options: - respect_severity_threshold: true - markdown: - enabled: true - options: - include_summary: true - include_findings_table: false - include_detailed_findings: true - max_detailed_findings: 20 - top_hotspots_limit: 10 - use_collapsible_details: true - ocsf: - enabled: true - options: {} - sarif: - enabled: true - options: {} - spdx: - enabled: false - options: {} - text: - enabled: true - options: - include_summary: true - include_findings_table: false - include_detailed_findings: false - max_detailed_findings: 20 - top_hotspots_limit: 20 - yaml: - enabled: false - options: {} diff --git a/.ash/.gitignore b/.ash/.gitignore deleted file mode 100644 index d831134..0000000 --- a/.ash/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# ASH default output directory (and variants) -ash_output* diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index d0d65f4..b5288fc 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -7,7 +7,7 @@ on: - "aiml-security-assessment/functions/security/**" - "tests/**" - "consolidate_html_reports.py" - - "test_consolidate_finserv.py" + - "tests/test_consolidate_finserv.py" - ".github/workflows/python-tests.yml" pull_request: branches: [main] @@ -15,7 +15,7 @@ on: - "aiml-security-assessment/functions/security/**" - "tests/**" - "consolidate_html_reports.py" - - "test_consolidate_finserv.py" + - "tests/test_consolidate_finserv.py" - ".github/workflows/python-tests.yml" jobs: @@ -81,7 +81,7 @@ jobs: AWS_ACCESS_KEY_ID: testing AWS_SECRET_ACCESS_KEY: testing # pragma: allowlist secret run: | - python -m pytest test_consolidate_finserv.py -v --tb=short + python -m pytest tests/test_consolidate_finserv.py -v --tb=short cd aiml-security-assessment/functions/security/generate_consolidated_report python -m pytest test_generate_report.py -v --tb=short diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md deleted file mode 100644 index 537ab51..0000000 --- a/FOLLOWUPS.md +++ /dev/null @@ -1,196 +0,0 @@ -# Follow-up items (deferred from PR #23 Round 3) - -> GitHub Issues are disabled on the fork `mehtadman87/sample-aiml-security-assessment`, so -> deferred work is tracked here in-repo (task T1b.7). Promote to an upstream issue/PR when ready. - -## FU-1 — Evaluate adopting a tool-wide `CRITICAL` severity tier - -**Status:** Deferred (intentionally out of scope for Round 3). - -**Background.** The FinServ severity methodology -([`docs/SECURITY_CHECKS_FINSERV_SEVERITY_METHODOLOGY.md`](./SECURITY_CHECKS_FINSERV_SEVERITY_METHODOLOGY.md)) -defines a Likelihood × Impact matrix mapped to the AWS Security Hub ASFF label set, which includes a -**CRITICAL** band (the Impact=High × Likelihood=High cell). Round 3 **capped that cell at High** and -did **not** introduce `Critical`, to keep the FinServ checks consistent with the upstream -Bedrock/SageMaker/AgentCore checks (which have no Critical tier). The drift-guard -(`tests/test_severity_register.py`) asserts no `Critical` is currently emitted. - -**Why it is a separate item.** Adopting `Critical` is a **tool-wide** change. Doing it for FinServ -alone would make FinServ inconsistent with the other three services. A tool-wide rollout touches: -- the shared `SeverityEnum` (each package's `schema.py`); -- all four assessment Lambdas (re-score the I=High × L=High controls); -- the report template severity filters/colors (`generate_consolidated_report/report_template.py` - and `consolidate_html_reports.py`); -- every service's unit tests plus the FinServ drift-guard. - -**Decision needed.** -1. Do we want a `CRITICAL` tier across the whole assessment? -2. If yes, which controls qualify (e.g., full guardrail bypass enabling a regulatory breach; - unauthorized high-value autonomous financial action)? - -**References:** methodology §2 (label scale) and §6 (the deferred decision); ASFF severity — -https://docs.aws.amazon.com/securityhub/1.0/APIReference/API_Severity.html - ---- - -## FU-2 — Report UI design for FinServ / OWASP (REQ-8) - -Broader report UI re-design (top-page FinServ summary box placement, OWASP grouping, Risk -Distribution treatment) is deferred to a separate PR per the reviewer's suggestion. Round 3 delivers -FinServ as a functionally first-class service (REQ-1, Wave 2); the UI-design discussion is tracked -here for a follow-up PR. - ---- ---- ---- - -## FU-3 — Shared-inventory refactor for the FinServ assessment Lambda (REQ-13 C3) - -**Status:** Deferred from Round 3.1 (Wave 5.5 task T5h.9). Classified **Should-Fix** (performance / -scalability optimization), not a correctness blocker. Tracked here for a focused follow-up PR. - -### Background - -The FinServ Lambda (`finserv_assessments/app.py`) runs all 64 checks **sequentially in a single -invocation** via `build_finserv_checks()`. There is no cross-check caching, so several checks -independently re-enumerate the **same** account-wide inventories, and many then issue an N+1 -per-resource call on top. The Round-3.1 pre-release audit (the per-check AWS API inventory) -identified the following duplicate full-account sweeps within one run: - -| Inventory (API) | Times enumerated | Checks performing the enumeration | Per-resource N+1 follow-up | -| --- | --- | --- | --- | -| `lambda:ListFunctions` | ~6× | FS-09, FS-52, FS-55, FS-58, FS-67, FS-69 | FS-09 `GetFunctionConcurrency` per function | -| `bedrock:ListGuardrails` | ~9× | FS-27a, FS-28, FS-36, FS-38, FS-45, FS-47, FS-50, FS-51, FS-59 | `GetGuardrail` per guardrail in each check | -| `bedrock:ListKnowledgeBases` | ~6× | FS-24, FS-31, FS-33, FS-48, FS-61, FS-65 | FS-31/33/65 `ListDataSources`/`GetDataSource` per KB | -| `s3:ListBuckets` (full account) | ~3–4× | FS-21, FS-46 (full sweep); FS-33, FS-65 (KB data-source buckets) | `GetBucketVersioning`/`GetBucketTagging`/`GetBucketNotification` per bucket | -| `wafv2:ListWebACLs` | 4× | FS-01, FS-53, FS-56, FS-68 | `GetWebACL` per ACL in each check | - -In small/empty accounts this is harmless (it is why the Wave-5 test account ran in ~3 minutes). In -**large enterprise accounts** — thousands of Lambda functions, hundreds of S3 buckets, many -guardrails/KBs/ACLs — the repeated full sweeps plus N+1 calls multiply API volume, drive adaptive -retries/throttling (`Config(retries=adaptive)`), and inflate wall-clock time. This is the most -likely driver of a slow run, and previously of a `States.Timeout`. - -### Why it was deferred (and why that is safe) - -1. **It is a large, regression-prone refactor.** It touches ~20 check functions and the way they - obtain AWS data, plus every corresponding mocked unit test (the per-check tests patch - `app.boto3.client`). Bundling that into a correctness-focused PR that "must be 100% sure" - (the reviewer's bar) trades correctness risk for a performance gain. -2. **The blast-radius risk it is associated with (audit C2) is already mitigated in Round 3.1.** - A `Catch [States.ALL] → "FinServ Assessment Incomplete"` was added to the FinServ task in - `statemachine/assessments.asl.json`, and the FinServ Lambda `Timeout` was raised 600 → 900 s. - So even if the FinServ Lambda is slow or times out in a very large account, the consolidated - report is still generated with the other services' findings and a visible incomplete marker — - the failure no longer sinks the whole assessment. -3. **It is purely an optimization** (Should-Fix). The disposition logic and severities are unchanged - by this work; nothing about the *correctness* of any finding depends on it. - -### Who is affected - -Customers running the FinServ assessment (`EnableFinServAssessment=true`) against accounts with large -resource estates — primarily large enterprises. Symptoms: long FinServ Lambda duration, CloudWatch -throttling/retry noise, and (pre-mitigation) the risk of a 900 s timeout. Small accounts are -unaffected. - -### Proposed design - -Collect each shared inventory **once** at the start of `lambda_handler`, into a read-only context -object, and pass it to the checks that need it — mirroring the existing `permission_cache` pattern -(which is already injected via `functools.partial` in `build_finserv_checks()`). - -1. **Add a `ResourceInventory` collected once per invocation**, e.g.: - - ```python - @dataclass - class ResourceInventory: - lambda_functions: list # lambda:ListFunctions (+ concurrency map) - guardrails: list # bedrock:ListGuardrails + GetGuardrail detail, keyed by id - knowledge_bases: list # bedrock:ListKnowledgeBases (+ data sources per KB) - buckets: list # s3:ListBuckets - web_acls: list # wafv2:ListWebACLs + GetWebACL detail (REGIONAL) - ``` - - Each field is populated by one paginated enumeration (reuse `_paginate`). Per-resource detail - (e.g., `GetGuardrail`, `GetWebACL`) is fetched once and stored alongside, eliminating the N+1 - repetition across checks. - -2. **Inject it like `permission_cache`.** In `build_finserv_checks(permission_cache, inventory)`, - bind the inventory to the relevant checks with `functools.partial`, so every registry entry - stays uniformly zero-arg and the handler loop is unchanged. - -3. **Make collection resilient.** A failure (e.g., `AccessDenied`) collecting one inventory must not - abort the others or the whole run. Per-inventory collection should be wrapped so a failed - inventory yields an explicit "unavailable" sentinel; checks that depend on an unavailable - inventory then emit `COULD_NOT_ASSESS` (consistent with `_is_access_error` handling today) rather - than a false `Failed`/`Passed`. Preserve the existing per-check `try/except` safety net. - -4. **Keep region/pagination semantics identical.** Same default-region clients, same `_paginate` - token handling; this is a call-site consolidation, not a behavior change. - -### Test strategy - -- Update the affected per-check unit tests to pass a constructed `ResourceInventory` (or a partial) - instead of patching `boto3.client` for the enumeration calls. Checks that still make non-inventory - calls keep their existing mocks. -- Add tests for the collector itself: one enumeration per inventory; pagination; and the - per-inventory failure path producing the "unavailable" sentinel (→ `COULD_NOT_ASSESS` downstream). -- **Behavior-preserving guarantee:** every existing disposition test (Passed/Failed/N/A per check) - and the severity drift-guard (`tests/test_severity_register.py`) must remain green with **no** - disposition or severity changes. That equivalence is the acceptance bar. -- Optionally add a counter/assertion (in a unit harness) proving each inventory API is called at - most once per handler invocation. - -### Acceptance criteria - -- Each shared inventory (`ListFunctions`, `ListGuardrails`+`GetGuardrail`, `ListKnowledgeBases`, - `ListBuckets`, `ListWebACLs`+`GetWebACL`) is enumerated **at most once** per FinServ run. -- No change to any finding's status or severity (all existing tests + the drift-guard pass - unchanged). -- A per-inventory collection failure degrades only the dependent checks (to `COULD_NOT_ASSESS`), not - the whole run. -- Workspace `finserv_assessments/` stays byte-identical to the fork copy after sync. - -### Risk / considerations - -- **Test isolation** if any memoization is module-level: prefer an explicit per-invocation object - passed as an argument over a module-global cache, to avoid state leaking across unit tests. -- **Partial-inventory correctness:** ensure a check that needs two inventories handles one being - unavailable independently. -- **Memory:** holding full inventories (e.g., all guardrail details) in memory is bounded and far - smaller than the permission cache already loaded; no concern at 1024 MB. - -### Effort estimate - -Roughly 1–2 focused days: ~0.5 day for the collector + injection, ~0.5–1 day updating the ~20 -affected checks and their tests, ~0.5 day validation (full suite + a large-account timing check). - -### References - -- Round-3.1 requirement: **REQ-13 (audit finding C3)** and design section "REQ-13 — Enterprise-scale - resilience & scope (audit C)" in `.kiro/specs/pr-review-round3-fixes/design.md`. -- Deferred task: **T5h.9** in `.kiro/specs/pr-review-round3-fixes/tasks.md`. -- Related mitigation already shipped in Round 3.1: **T5h.8** (ASL `Catch` on the FinServ task + - Lambda `Timeout` 600 → 900 s) addressing audit finding C2. -- Existing pattern to mirror: the `permission_cache` injection in `get_permissions_cache()` / - `build_finserv_checks()` in `finserv_assessments/app.py`. - -## FU-4 — Migrate upstream schemas from Pydantic V1 `@validator` to V2 `@field_validator` - -**Priority:** Low (tech-debt) — not a blocker. - -The upstream `schema.py` files for the Bedrock, SageMaker, AgentCore, consolidated-report, -and IAM-permission-caching Lambdas still use the deprecated Pydantic V1 `@validator` decorator, -which emits `PydanticDeprecatedSince20` warnings and will break when Pydantic V3 removes V1-style -validators. The FinServ `schema.py` already uses the V2 `@field_validator` form. - -**Why deferred:** this PR is scoped to `feature/finserv-risk-checks`. The affected files are -upstream/shared components; migrating them here would exceed the PR's scope and risk merge -conflicts with upstream. Best handled as a dedicated upstream change. - -**Scope:** swap `@validator("X")` → `@field_validator("X")` + `@classmethod`, adjust signatures, -and re-run each module's tests. - -### References - -- Identified in the pre-Wave-6 verification pass (`.kiro/specs/pr-review-round3-fixes/tasks.md`). diff --git a/README.md b/README.md index 7ff75a7..3508f66 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![License: MIT-0](https://img.shields.io/badge/License-MIT--0-yellow.svg)](https://opensource.org/licenses/MIT-0) [![Python 3.12+](https://img.shields.io/badge/Python-3.12+-blue.svg)](https://www.python.org/downloads/) [![AWS SAM](https://img.shields.io/badge/AWS-SAM-orange.svg)](https://aws.amazon.com/serverless/sam/) [![Serverless](https://img.shields.io/badge/Architecture-Serverless-green.svg)](https://aws.amazon.com/serverless/) -**Open-source automated security scanner for Amazon Bedrock, Amazon SageMaker AI, Amazon Bedrock AgentCore, and Financial Services GenAI Risk** — Built on [AWS Well-Architected Framework (Generative AI Lens)](https://docs.aws.amazon.com/wellarchitected/latest/generative-ai-lens/generative-ai-lens.html) +**Open-source automated security scanner for Amazon Bedrock, Amazon SageMaker AI, Amazon Bedrock AgentCore, and Financial Services GenAI Risk** — Built on the [AWS Well-Architected Framework (Generative AI Lens)](https://docs.aws.amazon.com/wellarchitected/latest/generative-ai-lens/generative-ai-lens.html) and optional [AWS User Guide to Governance, Risk, and Compliance for Responsible AI Adoption within Financial Services Industries](https://d1.awsstatic.com/onedam/marketing-channels/website/aws/en_US/whitepapers/compliance/AWS-User-Guide-Governance-Risk-Compliance-for-Responsible-AI-Adoption-Financial-Services.pdf) guidance. -Cloud security automation with **[116 security checks](docs/SECURITY_CHECKS.md)** for your generative AI and machine learning workloads. Identify IAM misconfigurations, encryption gaps, network isolation issues, and compliance violations with interactive HTML reports and actionable remediation guidance. +Cloud security automation with **[116 security checks](docs/SECURITY_CHECKS.md)** for your generative AI and machine learning workloads. Identify IAM misconfigurations, encryption gaps, network isolation issues, and potential governance or compliance gaps with interactive HTML reports and actionable remediation guidance. --- @@ -38,7 +38,8 @@ The framework generates professional, interactive security assessment reports wi - **Executive Summary** with severity counts and service breakdown - **Priority Recommendations** highlighting critical issues requiring immediate attention - **[116 Security Checks](docs/SECURITY_CHECKS.md)** across Amazon Bedrock, Amazon SageMaker AI, Amazon Bedrock AgentCore, and Financial Services GenAI Risk -- **Interactive Filtering** by account, service, severity, and status +- **Multi-Region Support** for core Bedrock, SageMaker, and AgentCore checks, with per-region risk breakdown +- **Interactive Filtering** by account, region, service, severity, and status - **Light/Dark Mode Toggle** with persistent user preference - **Text Search** across all findings with real-time results - **Direct AWS Documentation Links** for each finding with remediation guidance @@ -73,14 +74,14 @@ The framework generates professional, interactive security assessment reports wi This serverless assessment framework automatically evaluates your AI/ML workloads against AWS security best practices. It uses AWS serverless services to gather data from the control plane and generate reports containing the status of various security checks, severity levels, and recommended actions. -Designed for workloads using [Amazon Bedrock](https://aws.amazon.com/bedrock/), [Amazon Bedrock AgentCore](https://aws.github.io/bedrock-agentcore-starter-toolkit/), or [Amazon SageMaker AI](https://aws.amazon.com/sagemaker/ai/). +Designed for workloads using [Amazon Bedrock](https://aws.amazon.com/bedrock/), [Amazon Bedrock AgentCore](https://aws.github.io/bedrock-agentcore-starter-toolkit/), [Amazon SageMaker AI](https://aws.amazon.com/sagemaker/ai/), or the optional Financial Services GenAI risk assessment. ### Why Use This Framework? | Challenge | How This Framework Helps | |-----------|-------------------------| | **Manual security audits are time-consuming** | Fully automated scanning with one-click CloudFormation deployment | -| **Inconsistent security checks across teams** | Standardized 116-check assessment based on AWS Well-Architected best practices and AWS FinServ GenAI Risk guidance | +| **Inconsistent security checks across teams** | Standardized 116-check assessment based on AWS Well-Architected Generative AI Lens best practices and AWS Responsible AI governance, risk, and compliance guidance for financial services | | **Difficulty tracking AI/ML security posture** | Interactive HTML dashboards with severity breakdown and per-account visibility | | **Multi-account complexity** | Consolidated reporting across AWS Organizations with cross-account role assumption | | **Compliance and audit support** | Exportable reports to supplement your compliance program, with remediation guidance linked to AWS documentation | @@ -90,7 +91,7 @@ Designed for workloads using [Amazon Bedrock](https://aws.amazon.com/bedrock/), - **[Amazon Bedrock](docs/SECURITY_CHECKS.md#amazon-bedrock-security-checks-14)** (14 checks) - Guardrails, encryption, Amazon VPC endpoints, AWS IAM permissions, model invocation logging - **[Amazon SageMaker AI](docs/SECURITY_CHECKS.md#amazon-sagemaker-ai-security-checks-25)** (25 checks) - AWS Security Hub controls (SageMaker.1-5), encryption, network isolation, AWS IAM, MLOps - **[Amazon Bedrock AgentCore](docs/SECURITY_CHECKS.md#amazon-bedrock-agentcore-security-checks-13)** (13 checks) - Amazon VPC configuration, encryption, observability, resource policies -- **[Financial Services GenAI Risk](docs/SECURITY_CHECKS.md#financial-services-genai-risk-checks-64-additional-5-upstream-extensions)** (64 checks) - Unbounded consumption, excessive agency, supply chain, training data poisoning, hallucination, prompt injection, PII disclosure, and 8 more FinServ-specific risk categories derived from the [AWS FinServ GenAI Risk Guide](https://d1.awsstatic.com/onedam/marketing-channels/website/public/global-FinServ-ComplianceGuide-GenAIRisks-public.pdf) +- **[Financial Services GenAI Risk](docs/SECURITY_CHECKS.md#financial-services-genai-risk-checks-64-additional-5-upstream-extensions)** (64 checks) - Unbounded consumption, excessive agency, supply chain, training data poisoning, hallucination, prompt injection, PII disclosure, and 8 more FinServ-specific risk categories derived from the [AWS User Guide to Governance, Risk, and Compliance for Responsible AI Adoption within Financial Services Industries](https://d1.awsstatic.com/onedam/marketing-channels/website/aws/en_US/whitepapers/compliance/AWS-User-Guide-Governance-Risk-Compliance-for-Responsible-AI-Adoption-Financial-Services.pdf). See the [AWS Security Blog announcement](https://aws.amazon.com/blogs/security/introducing-the-updated-aws-user-guide-to-governance-risk-and-compliance-for-responsible-ai-adoption/) for context on the updated guide. **Deployment Options:** - **Single-Account**: Assess security in one AWS account @@ -112,7 +113,7 @@ This tool operates within the [AWS Shared Responsibility Model](https://aws.amaz **No guarantee of security or compliance.** This framework identifies common misconfigurations based on AWS best practices and the AWS Well-Architected Framework. It does not cover all possible security risks, does not replace formal compliance audits (SOC 2, HIPAA, and similar), and does not guarantee that your workloads are secure. Use the results as one input into your broader security program. -**52 checks across three services.** The assessment covers Amazon Bedrock, Amazon SageMaker AI, and Amazon Bedrock AgentCore. Other AI/ML services (Amazon Comprehend, Amazon Rekognition, Amazon Textract, and others) are not currently assessed. +**116 checks across four domains.** The assessment covers Amazon Bedrock, Amazon SageMaker AI, Amazon Bedrock AgentCore, and optional Financial Services GenAI risk checks. Other AI/ML services (Amazon Comprehend, Amazon Rekognition, Amazon Textract, and others) are not currently assessed. --- @@ -127,136 +128,83 @@ This tool operates within the [AWS Shared Responsibility Model](https://aws.amaz ## Prerequisites -- Python 3.12+ - [Install Python](https://www.python.org/downloads/) -- AWS SAM CLI - [Install the AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) -- Docker (optional) - [Install Docker community edition](https://hub.docker.com/search/?type=edition&offering=community) - Only required for local development and testing, not for AWS deployment +- Python 3.12+ — [Install Python](https://www.python.org/downloads/) +- AWS SAM CLI — [Install the AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) +- Docker (optional) — [Install Docker](https://hub.docker.com/search/?type=edition&offering=community) — Only required for local development + +--- ## Single-Account Deployment -1. Download the [aiml-security-single-account.yaml](deployment/aiml-security-single-account.yaml) AWS CloudFormation template. +1. Download the [aiml-security-single-account.yaml](deployment/aiml-security-single-account.yaml) CloudFormation template. 2. **[Deploy to AWS CloudFormation](https://console.aws.amazon.com/cloudformation/home#/stacks/create/template?stackName=aiml-security-single-account)** -3. Upload the AWS CloudFormation template from step 1. -4. Provide a stack name and optionally specify your email address to receive notifications. -5. Leave all other parameters at their default values. -6. Navigate to the next page, read and acknowledge the notice, and click **Next**. -7. Review the information and click **Submit**. -8. Wait for the AWS CloudFormation stack to complete. -9. Once complete, AWS CodeBuild automatically deploys the assessment stack and runs the assessment. -10. To view results: - - Navigate to the AWS CloudFormation console - - Open the stack you deployed (for example, `aiml-security-single-account` or your custom name) - - Go to the **Outputs** tab - - Copy the `AssessmentBucket` value - - Navigate to that Amazon S3 bucket and open the `{account_id}/security_assessment_*.html` file - -### Understanding Stack Names - -> **Important**: The deployment creates **TWO** AWS CloudFormation stacks. Only one contains your results! +3. Upload the template and provide a stack name. +4. Optionally specify your email address to receive notifications. +5. **(Optional) Multi-Region**: Set `TargetRegions` to scan multiple regions: + - Leave empty to scan only the deployment region (default) + - Comma- or space-separated list (for example, `us-east-1,us-west-2,eu-west-1` or `us-east-1 us-west-2 eu-west-1`) + - `all` to scan all regions where the services are available +6. Acknowledge IAM capabilities and click **Submit**. +7. Once complete, CodeBuild automatically runs the assessment. +8. View results: go to the stack **Outputs** tab → copy `AssessmentBucket` → open the report under the `/{account_id}/` prefix in that S3 bucket. + +> **Tip**: The deployment creates two stacks. Your results are in the stack *you named*, not the auto-generated `aiml-sec-*` stack. See [Troubleshooting](docs/TROUBLESHOOTING.md#8-confused-by-multiple-cloudformation-stacks) for details. - - - - - - - - - - - - - - - - - - - -
Stack TypeHow to IdentifyWhat It ContainsWhat to Do
Infrastructure Stack
(This is the one you need)
-The name you chose
-Examples:
- - my-aiml-assessment
- - aiml-security-prod
- - aiml-security-single-account -
-AWS CodeBuild project
-Amazon S3 bucket for results
-AWS IAM roles
-The "AssessmentBucket" output -
-Use this stack to view results!

-1. Open this stack in console
-2. Go to Outputs tab
-3. Copy AssessmentBucket value -
Assessment Stack
(Auto-generated - ignore this)
-Auto-generated name:
-Single-account: aiml-sec-{account_id}
-Multi-account: aiml-security-{account_id} per member account, plus aiml-security-mgmt for the management account
-Examples:
-aiml-sec-123456789012 (single)
-aiml-security-123456789012 (multi) -
-AWS Lambda functions
-AWS Step Functions
-Internal resources
-No outputs you need -
-Don't use this stack!

-It's for internal operations only.
-Created automatically by AWS CodeBuild. -
- -**Quick Check**: If you see a stack name starting with `aiml-sec-` or `aiml-security-` followed by numbers (or `aiml-security-mgmt`), that's an **auto-generated assessment stack**. Look for the stack name you originally chose during deployment. +--- ## Multi-Account Deployment -### Prerequisites +### Step 1: Deploy Member Roles -- AWS Organizations setup with management account access or delegated administrator privileges. +Deploy [1-aiml-security-member-roles.yaml](deployment/1-aiml-security-member-roles.yaml) to all target accounts using CloudFormation StackSets with service-managed permissions. -The deployment follows a two-step approach: +1. Navigate to **CloudFormation** > **StackSets** in the AWS Organizations management account or delegated administrator account +2. Upload the template and set `ManagementAccountID` to the account ID where the central multi-account CodeBuild project runs +3. Select **Service-managed permissions** and target your OUs +4. Select your target region and submit -### Step 1: Deploy Member Roles (AWS CloudFormation StackSets) +### Step 2: Deploy Central Infrastructure -Deploy [1-aiml-security-member-roles.yaml](deployment/1-aiml-security-member-roles.yaml) to all target accounts using AWS CloudFormation StackSets with service-managed permissions. +Deploy [2-aiml-security-codebuild.yaml](deployment/2-aiml-security-codebuild.yaml) in your central assessment account. This can be your AWS Organizations management account or a delegated administrator/central tooling account. -#### AWS Console Deployment +1. Upload the template and set `MultiAccountScan` to `true` +2. Optionally set `TargetRegions` for multi-region scanning +3. Optionally provide an email address for notifications +4. Acknowledge IAM capabilities and submit +5. Stack creation automatically triggers the assessment across all accounts -1. Navigate to **AWS CloudFormation** > **StackSets** in the management account -2. Click **Create StackSet** -3. Select **Upload a template file** and upload [1-aiml-security-member-roles.yaml](deployment/1-aiml-security-member-roles.yaml) -4. Enter a StackSet name (for example, `aiml-security-member-roles`) -5. Set the `ManagementAccountID` parameter to your management account ID -6. Under **Permissions**, select **Service-managed permissions** -7. Under **Deployment targets**, select the Organizational Units (OUs) containing your target accounts -8. Select **us-east-1** (or your target region) under **Specify regions** -9. Review and click **Submit** +--- -This uses AWS Organizations to deploy the member role to all accounts in the selected OUs. New accounts added to those OUs will automatically receive the role. +## Multi-Region Scanning -### Step 2: Deploy Central Infrastructure +Both deployment modes support scanning multiple AWS regions in parallel via the `TargetRegions` parameter: + +| Value | Behavior | +|-------|----------| +| Empty (default) | Scans deployment region only — fully backward compatible | +| Comma- or space-separated (for example, `us-east-1,us-west-2` or `us-east-1 us-west-2`) | Scans those regions in parallel | +| `all` | Discovers and scans all regions where assessed services are available | -Deploy [2-aiml-security-codebuild.yaml](deployment/2-aiml-security-codebuild.yaml) in your central management account or delegated administrator member account. +Scanning uses a Step Functions Map state, so multiple regions execute in parallel with no additional time cost. Services unavailable in a region produce an informational N/A finding. -#### AWS Console Deployment +The HTML report includes a Region column, filter dropdown, and "Risk by Region" summary. -1. Navigate to [AWS CloudFormation](https://console.aws.amazon.com/cloudformation/home#/stacks/create/template?stackName=aiml-security-multi-account) -2. Select **Upload a template file** and upload the [2-aiml-security-codebuild.yaml](deployment/2-aiml-security-codebuild.yaml) file. -3. Set the `MultiAccountScan` parameter to `true`. -4. Optionally, provide your email address in the `EmailAddress` parameter for completion notifications. -5. Optionally, set `EnableFinServAssessment` to `true` to run the Financial Services GenAI risk checks (FS-01..FS-69). It defaults to `false`; enable it only if you must adhere to FinServ compliance, as it adds a dedicated FinServ section to the report. See [How finding severities are determined](#how-finding-severities-are-determined) and the [FinServ check references](docs/SECURITY_CHECKS_FINSERV_COMMON.md). -6. Leave the remaining parameters at their default values. -6. Navigate to the next page, read and acknowledge the notice, and click **Next**. -7. Review the information and click **Submit**. -8. Stack creation automatically triggers AWS CodeBuild, which deploys the assessment to each account and runs it. +> **Upgrading an existing deployment?** See [Troubleshooting](docs/TROUBLESHOOTING.md#9-upgrading-an-existing-deployment-to-multi-region) — it's a simple stack parameter update with no teardown. + +--- ## How It Works +1. **Deploy** — CloudFormation creates CodeBuild, S3, IAM roles, and a Lambda trigger +2. **CodeBuild runs** — builds and deploys the SAM assessment stack (per account in multi-account mode) +3. **Step Functions execute** — orchestrates: S3 cleanup → IAM permission caching → resolve regions → Map state fans out per-region assessments (Bedrock, SageMaker, AgentCore in parallel) → optionally run FinServ checks → generate consolidated report +4. **Results** — HTML and CSV reports are stored in your S3 bucket + ### Optional: Financial Services GenAI Risk Checks (`EnableFinServAssessment`) The 64 Financial Services (FS-XX) GenAI risk checks are **opt-in** and default to `false`. Set the -`EnableFinServAssessment` deployment parameter to `true` only if you must adhere to FinServ -compliance. When enabled, the FinServ assessment Lambda runs and its findings appear in a dedicated +`EnableFinServAssessment` deployment parameter to `true` when you want the additional Financial +Services GenAI risk assessment. When enabled, the FinServ assessment Lambda runs and its findings appear in a dedicated **Financial Services** section of the HTML report. When left `false`, no FinServ findings are produced and the report omits the FinServ section entirely. The toggle is threaded into the Step Functions execution input (`enableFinServ`); the FinServ Lambda is always deployed but is invoked @@ -266,23 +214,20 @@ only when the flag is `true`. #### Scope and limitations -- **Single Region per run.** The assessment evaluates resources in the deployment Region only (the assessment Lambdas use their own Region). Region-scoped controls — WAF, API Gateway, Bedrock guardrails and Knowledge Bases, OpenSearch Serverless, Lambda, and SageMaker monitoring — are not evaluated in other Regions. For multi-Region GenAI workloads, deploy and run the assessment in each Region. +- **FinServ Region scope.** Core Bedrock, SageMaker, AgentCore, and optional FinServ checks use the resolved `TargetRegions` from the deployment parameters. FinServ findings are emitted with Region values so they appear alongside the same regional filter and per-region report views as the core service checks. - **Heuristic and advisory checks.** Some controls cannot be verified through an API (application-layer controls, dataset contents, resource associations); these are reported as `ADVISORY`/`N/A` and require manual review. See [How finding severities are determined](#how-finding-severities-are-determined). - **Permissions.** A check that lacks an IAM permission is reported as `COULD NOT ASSESS` (not a failure). Re-deploy the member role after any IAM template change so newer actions take effect. -### Single-Account Mode (`MultiAccountScan=false`) +For detailed architecture, execution flow, and extension guidance, see the [Developer Guide](docs/DEVELOPER_GUIDE.md). -- Creates a local `AIMLSecurityMemberRole` -- Runs the assessment in the same account -- Uses a local Amazon S3 bucket for results +--- -### Multi-Account Mode (`MultiAccountScan=true`) +## Viewing Results -- Lists all active accounts in AWS Organizations -- Assumes the `AIMLSecurityMemberRole` in each target account -- Deploys selected assessment modules in each account with a shared Amazon S3 bucket -- Executes AWS Step Functions for each deployed module in each account -- Consolidates results by assessment type in a central Amazon S3 bucket +1. Open your **infrastructure stack** in CloudFormation → **Outputs** tab → copy `AssessmentBucket` +2. Navigate to that S3 bucket +3. For single-account, open `{account_id}/security_assessment_single_account_*.html` +4. For multi-account, open `consolidated-reports/security_assessment_multi_account_*.html` ### Assessment Execution Process @@ -308,24 +253,6 @@ only when the flag is `true`. 7. **Reporting**: Generates multi-account HTML and CSV reports 8. **Notification**: Sends completion notification through Amazon SNS (if configured) -## Permissions Required - -### Central Account Role (`MultiAccountCodeBuildRole`) - -- Assumes roles in member accounts -- Lists AWS Organizations accounts -- Deploys AWS CloudFormation/AWS SAM applications -- Executes AWS Step Functions -- Writes to the Amazon S3 bucket - -### Member Account Role (`AIMLSecurityMemberRole`) - -- Read-only access to AI/ML services (Amazon Bedrock, Amazon SageMaker AI, Amazon Bedrock AgentCore, and FinServ-specific services: AWS WAF, AWS Shield, Amazon Macie, AWS Organizations, Amazon OpenSearch Serverless) -- AWS IAM read permissions for security assessment -- AWS CloudTrail, Amazon GuardDuty, and AWS Lambda read permissions -- Amazon VPC and Amazon EC2 read permissions -- Amazon ECR, Amazon CloudWatch Logs, and AWS X-Ray read permissions (for Amazon Bedrock AgentCore) - ## Monitoring and Results - **Amazon S3 Bucket**: Central storage for all assessment results @@ -333,8 +260,6 @@ only when the flag is `true`. - **Amazon SNS Notifications**: Email alerts on completion/failure - **Amazon EventBridge Rules**: Automated workflow triggers -## Viewing Assessment Results - You can check the AWS CodeBuild console to confirm the assessment completed successfully before accessing the results. ### Accessing Results @@ -351,7 +276,7 @@ You can check the AWS CodeBuild console to confirm the assessment completed succ 2. **Navigate to the Amazon S3 Bucket**: - Go to **Amazon S3** in the AWS Console - Search for and open your assessment bucket - - For single-account deployments, open the `security_assessment_XXXXX.html` report + - For single-account deployments, open the `{account_id}/` folder and then open the `security_assessment_single_account_YYYYMMDD_HHMMSS.html` report - For multi-account deployments, follow the [Report Structure](#report-structure) guidance below ### Report Structure @@ -360,13 +285,13 @@ You can check the AWS CodeBuild console to confirm the assessment completed succ - **Location**: `consolidated-reports/` folder in the bucket - **Content**: Multi-account HTML report combining all account assessments -- **File Format**: `multi_account_report_YYYYMMDD_HHMMSS.html` +- **File Format**: `security_assessment_multi_account_YYYYMMDD_HHMMSS.html` - **Features**: - Executive summary with metrics (Total, High, Medium, Low severity counts) - Service breakdown (Amazon Bedrock, Amazon SageMaker AI, Amazon Bedrock AgentCore, Financial Services GenAI Risk) - Priority recommendations - Light/dark mode toggle (persists through localStorage) - - Dropdown filters for Account ID, Severity, Status + - Dropdown filters for Account ID, Region, Service, Severity, Status - Text search filter for findings - "View Docs" buttons for reference links @@ -381,23 +306,24 @@ You can check the AWS CodeBuild console to confirm the assessment completed succ - `finserv_security_report_{execution_id}.csv` - Financial Services GenAI risk assessment results (64 FS-XX checks) - `permissions_cache_{execution_id}.json` - IAM permissions cache - - `security_assessment_{timestamp}_{execution_id}.html` - Consolidated HTML report (same features as multi-account report) + - `security_assessment_single_account_{timestamp}.html` - Consolidated HTML report (same features as multi-account report) ### Understanding Results -| Severity | Description | -|----------|-------------| -| **High** | Critical security issues requiring immediate attention | -| **Medium** | Important security improvements recommended | -| **Low** | Minor optimizations suggested | -| **Informational** | Advisory information, no action required | -| **N/A** | Check not applicable (no resources to assess) | - -| Status | Description | -|--------|-------------| -| **Failed** | Security issue identified that requires remediation | -| **Passed** | Checked resources met the assessed best practice at time of scan | -| **N/A** | No resources exist to check (for example, no notebooks, no guardrails configured) | +| Severity | Meaning | +|----------|---------| +| **High** | Critical — immediate action required | +| **Medium** | Important — should be addressed | +| **Low** | Minor — best practice optimization | +| **Informational** | Advisory — no action required | + +| Status | Meaning | +|--------|---------| +| **Failed** | Security issue identified | +| **Passed** | Resource meets best practice | +| **N/A** | No resources to assess or service not available in region | + +--- ### How finding severities are determined @@ -427,119 +353,30 @@ preliminary — validate with your MRM/Legal/Compliance teams before relying on ## Customization -### Adding New Accounts +| Task | How | +|------|-----| +| Add new accounts | Add to StackSet deployment targets | +| Modify permissions scope | Edit `1-aiml-security-member-roles.yaml` | +| Adjust concurrency | Change `ConcurrentAccountScans` parameter | +| Add new service checks | See [Developer Guide](docs/DEVELOPER_GUIDE.md#adding-new-aiml-service-assessments) | -#### Option A: AWS Console - -1. Navigate to **AWS CloudFormation** > **StackSets** -2. Select `aiml-security-member-roles` AWS CloudFormation StackSet -3. Click **Add stacks to StackSet** -4. Choose deployment targets: - - **Deploy to accounts**: Enter specific account IDs - - **Regions**: Select target regions -5. Review and click **Submit** - -### Modifying Assessment Scope - -To add or remove service permissions, edit the member role permissions in `1-aiml-security-member-roles.yaml`. - -### Concurrent Scanning - -Adjust the `ConcurrentAccountScans` parameter based on your organization size and cost considerations. - -## Cleanup - -### Single-Account Cleanup - -To remove all resources deployed for single-account assessment: - -1. **Delete the AWS SAM-deployed assessment stack**: - - Navigate to **AWS CloudFormation** > **Stacks** - - Select the `aiml-sec-{account_id}` stack (for example, `aiml-sec-123456789012`) - - Click **Delete** - - Wait for stack deletion to complete - -2. **Delete the AWS CodeBuild infrastructure stack**: - - Select the `aiml-security-single-account` stack (or your custom stack name) - - Click **Delete** - - Wait for stack deletion to complete - -3. **Clean up Amazon S3 buckets** (if stack deletion fails due to non-empty buckets): - ```bash - # Empty the assessment bucket - aws s3 rm s3:// --recursive - - # If versioning is enabled, delete version markers - aws s3api delete-objects --bucket --delete \ - "$(aws s3api list-object-versions --bucket \ - --query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}')" - - # Delete the bucket - aws s3 rb s3:// - ``` - -### Multi-Account Cleanup - -To remove all resources deployed for multi-account assessment: - -1. **Delete AWS SAM-deployed stacks in each member account**: - - For each account that was scanned, navigate to **AWS CloudFormation** > **Stacks** - - Select the `aiml-security-{account_id}` stack (for example, `aiml-security-123456789012`) - - For the management account, select `aiml-security-mgmt` - - Click **Delete** - - Alternatively, use the AWS CLI to delete across accounts: - ```bash - # Assume role in member account and delete stack - aws cloudformation delete-stack --stack-name aiml-security- \ - --region - ``` - -2. **Delete the central AWS CodeBuild infrastructure stack**: - - In the management account, navigate to **AWS CloudFormation** > **Stacks** - - Select the `aiml-security-multi-account` stack - - Click **Delete** - - Wait for stack deletion to complete - -3. **Delete the AWS CloudFormation StackSet member roles**: - - Navigate to **AWS CloudFormation** > **StackSets** - - Select the `aiml-security-member-roles` AWS CloudFormation StackSet - - Click **Actions** > **Delete stacks from StackSet** - - Select all deployment targets (OUs or accounts) - - Wait for stack instances to be deleted - - Once all stack instances are removed, delete the AWS CloudFormation StackSet itself - -4. **Clean up Amazon S3 buckets** (if stack deletion fails due to non-empty buckets): - ```bash - # List and identify assessment buckets - aws s3 ls | grep aiml-security - - # Empty each bucket - aws s3 rm s3:// --recursive - - # Delete version markers if versioning was enabled - aws s3api delete-objects --bucket --delete \ - "$(aws s3api list-object-versions --bucket \ - --query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}')" - - # Delete the bucket - aws s3 rb s3:// - ``` - -### Cleanup Order +--- -For a clean removal, delete resources in this order: +## Permissions Required -1. **Assessment stacks** (auto-created by SAM): - - Single-account: `aiml-sec-{account_id}` (for example, `aiml-sec-123456789012`) - - Multi-account: `aiml-security-{account_id}` per member account, plus `aiml-security-mgmt` for management account +The deployment uses multiple IAM roles with different trust and permission boundaries. They are not all read-only. -2. **Infrastructure stack** (the stack you deployed manually): - - Single-account: Your chosen stack name (for example, `my-aiml-assessment`) - - Multi-account: `aiml-security-multi-account` or your chosen name +- **`CodeBuildRole` / `MultiAccountCodeBuildRole`**: orchestration roles used by the infrastructure stack to clone the repo, build SAM, deploy/update the assessment stack, and start Step Functions executions. These roles require infrastructure-management permissions such as CloudFormation, Lambda, IAM, Step Functions, and S3 actions. +- **`AIMLSecurityMemberRole`**: role assumed in the target account during single-account and multi-account runs. In the multi-account flow this role is also **not read-only**. It needs both service-read permissions for the checks and deployment permissions so CodeBuild can create or update the per-account SAM assessment stack. +- **SAM-created Lambda execution roles**: runtime roles for the assessment functions. These are the closest thing to read-only assessment roles. They primarily use `List*`, `Describe*`, and `Get*` access against Bedrock, SageMaker, AgentCore, IAM analysis APIs, and supporting read APIs, plus S3 access to write reports and read the cached IAM permissions file. -3. AWS CloudFormation StackSet member roles (multi-account only) +If you need to reduce scope, review the role policies in: -4. Any remaining Amazon S3 buckets manually +- [deployment/aiml-security-single-account.yaml](deployment/aiml-security-single-account.yaml) +- [deployment/1-aiml-security-member-roles.yaml](deployment/1-aiml-security-member-roles.yaml) +- [deployment/2-aiml-security-codebuild.yaml](deployment/2-aiml-security-codebuild.yaml) +- [aiml-security-assessment/template.yaml](aiml-security-assessment/template.yaml) +- [aiml-security-assessment/template-multi-account.yaml](aiml-security-assessment/template-multi-account.yaml) --- @@ -548,35 +385,34 @@ For a clean removal, delete resources in this order: | Document | Description | |----------|-------------| | [Security Checks Reference](docs/SECURITY_CHECKS.md) | Complete reference for all 116 security checks with severity levels | -| [FinServ GenAI Risk Checks — Common](docs/SECURITY_CHECKS_FINSERV_COMMON.md) | Shared introduction, severity rubric, upstream-overlap table, and compliance framework mapping for FS-01..69 | -| [FinServ Part 1 — Infrastructure Controls](docs/SECURITY_CHECKS_FINSERV_PART1_INFRA_CONTROLS.md) | FS-01..26: Unbounded consumption, excessive agency, supply chain, training data poisoning, vector & embedding weaknesses | -| [FinServ Part 2 — Guardrails & Content Safety](docs/SECURITY_CHECKS_FINSERV_PART2_GUARDRAILS_CONTENT_SAFETY.md) | FS-27..46: Non-compliant output, misinformation, abusive/harmful output, biased output, PII disclosure | -| [FinServ Part 3 — App Layer & Gaps](docs/SECURITY_CHECKS_FINSERV_PART3_APP_LAYER_AND_GAPS.md) | FS-47..69: Hallucination, prompt injection, improper output handling, off-topic output, out-of-date training data, cross-category gap checks | +| [FinServ GenAI Risk Checks](docs/SECURITY_CHECKS_FINSERV.md) | Complete FS-01..69 reference: shared introduction, severity rubric, upstream-overlap table, compliance framework mapping, and all check definitions (Part 1 infrastructure controls, Part 2 guardrails & content safety, Part 3 app-layer controls & gaps) | | [FinServ Severity Methodology](docs/SECURITY_CHECKS_FINSERV_SEVERITY_METHODOLOGY.md) | Likelihood × Impact → ASFF severity model, disposition rules, and research basis for FS check severities | | [FinServ Severity Register](docs/SECURITY_CHECKS_FINSERV_SEVERITY_REGISTER.md) | Authoritative per-finding severity assignments (the single source of truth enforced by the drift-guard test) | -| [FinServ Compliance Mappings](docs/AIMLSecurityAssessment-MappingsTable.csv) | Machine-readable mapping of FS checks to SR 11-7, FFIEC CAT, NYDFS 500.06, PCI-DSS, DORA, MAS TRM, ISO 27001, OWASP LLM Top 10 | -| [Troubleshooting Guide](docs/TROUBLESHOOTING.md) | Common issues, debugging tips, and FAQ | +| [FinServ Compliance Mappings](docs/SECURITY_CHECKS_FINSERV.md#compliance-framework-mapping) | Preliminary mapping of FS checks to SR 11-7, FFIEC CAT, NYDFS 500, PCI-DSS, DORA, MAS TRM, ISO 27001, ECOA, and OWASP LLM Top 10 | +| [Troubleshooting Guide](docs/TROUBLESHOOTING.md) | Common issues, stack identification, upgrade guide, debugging | | [Developer Guide](docs/DEVELOPER_GUIDE.md) | Architecture details, adding custom checks, and contributing | +| [Cleanup Guide](docs/CLEANUP.md) | Step-by-step resource removal instructions | --- ## CI/CD -GitHub Actions workflows run automatically on pull requests and pushes to `main`: +GitHub Actions workflows run automatically on pull requests and selected pushes: | Workflow | Trigger | What It Checks | |----------|---------|----------------| -| **Python Code Quality** | PR | Runs `ruff check` and `ruff format --check` on changed Python files | +| **Python Code Quality** | PR | `ruff check` and `ruff format --check` on changed Python files | +| **AI/ML Security Assessment Tests** | PR, push to `main`/`develop` | Runs the `pytest` suite (assessment functions and report pipeline) on Python 3.11 and 3.12 | | **CloudFormation Lint** | PR | Validates deployment and SAM templates with `cfn-lint` | -| **SAM Validate & Build** | PR | Runs `sam validate --lint` and `sam build` on SAM templates | -| **ASH Security Scan** | PR | Scans changed files for secrets, dependency vulnerabilities, and IaC misconfigurations | -| **ASH Full Repository Scan** | Push to main, monthly | Full repository security scan with results uploaded as artifacts | +| **SAM Validate & Build** | PR | `sam validate --lint` and `sam build` on SAM templates | +| **ASH Security Scan** | PR | Scans for secrets, dependency vulnerabilities, and IaC misconfigurations | +| **ASH Full Repository Scan** | Push to main, monthly | Full repository security scan | --- ## Contributing -We welcome community contributions! Please see [Developer Guide](docs/DEVELOPER_GUIDE.md) for guidelines. +We welcome community contributions! See the [Developer Guide](docs/DEVELOPER_GUIDE.md) for guidelines. ## Security diff --git a/aiml-security-assessment/functions/security/agentcore_assessments/app.py b/aiml-security-assessment/functions/security/agentcore_assessments/app.py index cd72a65..595f9be 100644 --- a/aiml-security-assessment/functions/security/agentcore_assessments/app.py +++ b/aiml-security-assessment/functions/security/agentcore_assessments/app.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone from typing import Dict, List, Any from botocore.config import Config -from botocore.exceptions import ClientError +from botocore.exceptions import ClientError, EndpointConnectionError from schema import create_finding, SeverityEnum, StatusEnum @@ -26,26 +26,37 @@ # Configure boto3 with adaptive retry mode boto3_config = Config(retries=dict(max_attempts=10, mode="adaptive")) -# Initialize AWS clients +# Initialize S3 client (always uses Lambda's region for bucket operations) s3_client = boto3.client("s3", config=boto3_config) -iam_client = boto3.client("iam", config=boto3_config) -ec2_client = boto3.client("ec2", config=boto3_config) -ecr_client = boto3.client("ecr", config=boto3_config) -logs_client = boto3.client("logs", config=boto3_config) -xray_client = boto3.client("xray", config=boto3_config) -cloudwatch_client = boto3.client("cloudwatch", config=boto3_config) - -# Initialize AgentCore client -try: - agentcore_client = boto3.client("bedrock-agentcore-control", config=boto3_config) - logger.info("Successfully initialized bedrock-agentcore-control client") -except Exception as e: - logger.warning(f"Failed to initialize bedrock-agentcore-control client: {e}") - agentcore_client = None + +# Regional clients — initialized in lambda_handler with target region +iam_client = None +ec2_client = None +ecr_client = None +logs_client = None +xray_client = None +cloudwatch_client = None +agentcore_client = None # Environment variables BUCKET_NAME = os.environ.get("AIML_ASSESSMENT_BUCKET_NAME") +# IAM is a global service. Findings derived purely from the IAM permission cache +# (e.g. AC-02, AC-03) are identical across regions, so they are produced only on +# the primary region (Map index 0) and tagged with this region label to avoid +# duplicate findings when scanning multiple regions. +GLOBAL_REGION_LABEL = "Global" + +# Error codes returned when a region exists but is not enabled/usable for the +# account (opt-in regions, disabled regions). The availability probe treats +# these the same as an endpoint connection failure. +REGION_UNAVAILABLE_ERROR_CODES = { + "UnrecognizedClientException", + "InvalidClientTokenId", + "AuthFailure", + "OptInRequired", +} + # Execution tracking start_time = None @@ -129,6 +140,12 @@ def _agentcore_list_all( kwargs["nextToken"] = next_token response = list_method(**kwargs) + if not isinstance(response, dict): + logger.warning( + f"{list_method_name} returned unexpected response type: " + f"{type(response).__name__}" + ) + break for result_key in result_keys: page_items = response.get(result_key) @@ -137,6 +154,12 @@ def _agentcore_list_all( break next_token = response.get("nextToken") + if next_token is not None and not isinstance(next_token, str): + logger.warning( + f"{list_method_name} returned non-string nextToken: " + f"{type(next_token).__name__}" + ) + break if not next_token: break @@ -201,6 +224,7 @@ def generate_csv_report(findings: List[Dict[str, Any]]) -> str: "Reference", "Severity", "Status", + "Region", ], ) writer.writeheader() @@ -217,6 +241,7 @@ def generate_csv_report(findings: List[Dict[str, Any]]) -> str: "Reference", "Severity", "Status", + "Region", ], ) writer.writeheader() @@ -230,7 +255,9 @@ def generate_csv_report(findings: List[Dict[str, Any]]) -> str: return csv_content -def write_to_s3(execution_id: str, csv_content: str, bucket_name: str) -> str: +def write_to_s3( + execution_id: str, csv_content: str, bucket_name: str, region: str = "" +) -> str: """ Upload CSV report to S3. @@ -238,6 +265,7 @@ def write_to_s3(execution_id: str, csv_content: str, bucket_name: str) -> str: execution_id: Unique execution identifier csv_content: CSV content to upload bucket_name: S3 bucket name + region: AWS region identifier for the report filename Returns: S3 URL of uploaded file @@ -246,7 +274,10 @@ def write_to_s3(execution_id: str, csv_content: str, bucket_name: str) -> str: Exception: If upload fails """ try: - key = f"agentcore_security_report_{execution_id}.csv" + if region: + key = f"agentcore_security_report_{execution_id}_{region}.csv" + else: + key = f"agentcore_security_report_{execution_id}.csv" s3_client.put_object( Bucket=bucket_name, @@ -1505,9 +1536,40 @@ def check_agentcore_vpc_endpoints() -> List[Dict[str, Any]]: """ findings = [] + if agentcore_client is None: + findings.append( + create_finding( + check_id="AC-08", + finding_name="AgentCore VPC Endpoints Check", + finding_details="AgentCore client not available in this region", + resolution="Deploy in a region where Amazon Bedrock AgentCore is available", + reference="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/vpc.html", + severity=SeverityEnum.INFORMATIONAL, + status=StatusEnum.NA, + ) + ) + return findings + try: logger.info("Checking for AgentCore VPC endpoints") + runtimes_response = agentcore_client.list_agent_runtimes() + runtimes = runtimes_response.get("agentRuntimes", []) + + if not runtimes: + findings.append( + create_finding( + check_id="AC-08", + finding_name="AgentCore VPC Endpoints Check", + finding_details="No AgentCore resources found", + resolution="No action required", + reference="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/vpc.html", + severity=SeverityEnum.INFORMATIONAL, + status=StatusEnum.NA, + ) + ) + return findings + # Get all VPCs vpcs_response = ec2_client.describe_vpcs() vpcs = vpcs_response.get("Vpcs", []) @@ -1806,7 +1868,9 @@ def check_agentcore_resource_based_policies() -> List[Dict[str, Any]]: gateway_name = gateway.get("name", gateway_id) try: - gateway_details = agentcore_client.get_gateway(gatewayId=gateway_id) + gateway_details = agentcore_client.get_gateway( + gatewayIdentifier=gateway_id + ) gateway_arn = gateway_details.get("gatewayArn") if not gateway_arn: @@ -2171,7 +2235,9 @@ def check_agentcore_gateway_encryption() -> List[Dict[str, Any]]: gateway_name = gateway.get("name", gateway_id) try: - gateway_details = agentcore_client.get_gateway(gatewayId=gateway_id) + gateway_details = agentcore_client.get_gateway( + gatewayIdentifier=gateway_id + ) # Check for customer-managed KMS key encryption_key_arn = gateway_details.get( @@ -2388,47 +2454,181 @@ def lambda_handler(event, context): Lambda handler for AgentCore security assessment. Args: - event: Lambda event containing execution_id + event: Lambda event containing execution_id and Region context: Lambda context Returns: Response with status and S3 URL """ - global start_time + global start_time, iam_client, ec2_client, ecr_client, logs_client + global xray_client, cloudwatch_client, agentcore_client start_time = time.time() try: - # Extract execution ID + # Extract target region from Step Functions Map state + region = event.get("Region", os.environ.get("AWS_REGION", "us-east-1")) + # IAM is global: only the primary region (Map index 0) runs IAM-only checks. + is_primary_region = int(event.get("RegionIndex", 0)) == 0 + logger.info(f"Scanning region: {region} (primary={is_primary_region})") + execution_id = event.get("Execution", {}).get("Name", "unknown") - logger.info( - f"Starting AgentCore security assessment for execution: {execution_id}" + + # Initialize regional clients (iam is global, the rest are region-scoped) + iam_client = boto3.client("iam", config=boto3_config) + ec2_client = boto3.client("ec2", config=boto3_config, region_name=region) + ecr_client = boto3.client("ecr", config=boto3_config, region_name=region) + logs_client = boto3.client("logs", config=boto3_config, region_name=region) + xray_client = boto3.client("xray", config=boto3_config, region_name=region) + cloudwatch_client = boto3.client( + "cloudwatch", config=boto3_config, region_name=region ) - # Retrieve permission cache + # Collect all findings + all_findings = [] + + # Retrieve permission cache (shared/global IAM data) try: permission_cache = get_permissions_cache(execution_id) except Exception as e: logger.warning(f"Failed to retrieve permission cache: {e}") permission_cache = {"role_permissions": [], "user_permissions": []} - # Collect all findings - all_findings = [] + # Run global IAM-only checks once (on the primary region) so the same role + # violations are not reported once per scanned region. These run before the + # regional availability gate so they are still emitted even if AgentCore is + # not available in the primary region. + if is_primary_region: + global_checks = [ + ( + "IAM Full Access", + lambda: check_agentcore_full_access_roles(permission_cache), + ), + ( + "Stale Access", + lambda: check_stale_agentcore_access(permission_cache), + ), + # AC-09 inspects a global IAM service-linked role, so it is also + # run once on the primary region rather than per scanned region. + ("Service-Linked Role", check_agentcore_service_linked_role), + ] + for check_name, check_func in global_checks: + try: + logger.info(f"Running global check: {check_name}") + global_findings = check_func() + for finding in global_findings: + finding["Region"] = GLOBAL_REGION_LABEL + all_findings.extend(global_findings) + except Exception as e: + logger.error(f"Error in global check '{check_name}': {e}") + all_findings.append( + create_finding( + check_id="AC-00", + finding_name=f"AgentCore {check_name} Check Error", + finding_details=f"Error during {check_name} check: {str(e)}", + resolution="Investigate error and retry assessment", + reference="https://aws.github.io/bedrock-agentcore-starter-toolkit/", + severity=SeverityEnum.HIGH, + status=StatusEnum.FAILED, + region=GLOBAL_REGION_LABEL, + ) + ) + + # Reset per-invocation so a warm container cannot leak a previous + # region's client if creation below fails. + agentcore_client = None + try: + agentcore_client = boto3.client( + "bedrock-agentcore-control", config=boto3_config, region_name=region + ) + except Exception as e: + # The client could not even be constructed (e.g. the SDK in this + # runtime does not know the service). This is the one case where the + # region genuinely cannot be assessed. + logger.warning( + f"Failed to initialize bedrock-agentcore-control client: {e}" + ) + agentcore_client = None - # Execute all assessment checks + if agentcore_client is not None: + # Test service availability with a lightweight call + try: + agentcore_client.list_agent_runtimes(maxResults=1) + logger.info("Successfully initialized bedrock-agentcore-control client") + except EndpointConnectionError: + logger.info( + f"AgentCore service not available in region {region}, skipping" + ) + agentcore_client = None + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code", "") + if error_code in REGION_UNAVAILABLE_ERROR_CODES: + logger.info( + f"AgentCore not accessible in region {region} ({error_code}), skipping" + ) + agentcore_client = None + else: + # Service is reachable but returned another API error (e.g. access + # denied) — proceed; individual checks handle their own errors. + logger.info( + f"AgentCore client initialized (probe returned {error_code})" + ) + except Exception as e: + # An unexpected probe failure (e.g. a boto3/botocore SDK param or + # operation mismatch such as ParamValidationError/AttributeError) + # says nothing about regional availability. Treating it as "not + # available" would silently skip every AgentCore check and emit a + # false N/A report, so keep the client and let the individual + # checks surface their own errors instead. + logger.warning( + f"AgentCore availability probe raised an unexpected error, " + f"proceeding with checks: {e}" + ) + + # If AgentCore not available, produce an N/A report (plus any global IAM + # findings already collected on the primary region) and exit early + if agentcore_client is None: + all_findings.append( + create_finding( + check_id="AC-00", + finding_name="AgentCore Service Availability", + finding_details=f"Amazon Bedrock AgentCore is not available in region {region}. No checks performed.", + resolution="No action required. AgentCore is not deployed in this region.", + reference="https://aws.github.io/bedrock-agentcore-starter-toolkit/", + severity=SeverityEnum.INFORMATIONAL, + status=StatusEnum.NA, + region=region, + ) + ) + for finding in all_findings: + if not finding.get("Region"): + finding["Region"] = region + csv_content = generate_csv_report(all_findings) + s3_url = write_to_s3(execution_id, csv_content, BUCKET_NAME, region=region) + return { + "statusCode": 200, + "body": json.dumps( + { + "message": f"AgentCore not available in {region}", + "s3_url": s3_url, + } + ), + } + + logger.info( + f"Starting AgentCore security assessment for execution: {execution_id}" + ) + + # Execute regional assessment checks (IAM-only checks AC-02/AC-03 and the + # global service-linked role check AC-09 are run separately, once, on the + # primary region above) checks = [ ("VPC Configuration", check_agentcore_vpc_configuration), - ( - "IAM Full Access", - lambda: check_agentcore_full_access_roles(permission_cache), - ), - ("Stale Access", lambda: check_stale_agentcore_access(permission_cache)), ("Observability", check_agentcore_observability), ("Encryption", check_agentcore_encryption), ("Browser Tool Recording", check_browser_tool_recording), ("Memory Configuration", check_agentcore_memory_configuration), ("Gateway Configuration", check_agentcore_gateway_configuration), ("VPC Endpoints", check_agentcore_vpc_endpoints), - ("Service-Linked Role", check_agentcore_service_linked_role), ("Resource-Based Policies", check_agentcore_resource_based_policies), ("Policy Engine Encryption", check_agentcore_policy_engine_encryption), ("Gateway Encryption", check_agentcore_gateway_encryption), @@ -2455,7 +2655,6 @@ def lambda_handler(event, context): except Exception as e: logger.error(f"Error in check '{check_name}': {e}") - # Add error finding all_findings.append( create_finding( check_id="AC-00", @@ -2465,15 +2664,21 @@ def lambda_handler(event, context): reference="https://aws.github.io/bedrock-agentcore-starter-toolkit/", severity=SeverityEnum.HIGH, status=StatusEnum.FAILED, + region=region, ) ) + # Inject region into all findings that don't have it set + for finding in all_findings: + if not finding.get("Region"): + finding["Region"] = region + # Generate CSV report logger.info(f"Generating CSV report with {len(all_findings)} total findings") csv_content = generate_csv_report(all_findings) # Upload to S3 - s3_url = write_to_s3(execution_id, csv_content, BUCKET_NAME) + s3_url = write_to_s3(execution_id, csv_content, BUCKET_NAME, region=region) # Calculate execution metrics total_duration = time.time() - start_time diff --git a/aiml-security-assessment/functions/security/agentcore_assessments/schema.py b/aiml-security-assessment/functions/security/agentcore_assessments/schema.py index 6c5a9f5..16a66d9 100644 --- a/aiml-security-assessment/functions/security/agentcore_assessments/schema.py +++ b/aiml-security-assessment/functions/security/agentcore_assessments/schema.py @@ -1,6 +1,6 @@ from enum import Enum -from typing import Dict, Any -from pydantic import BaseModel, Field, validator +from typing import Any, Dict +from pydantic import BaseModel, Field, field_validator import re @@ -35,8 +35,12 @@ class Finding(BaseModel): Reference: str = Field(..., description="Documentation reference URL") Severity: SeverityEnum = Field(..., description="Severity level of the finding") Status: StatusEnum = Field(..., description="Current status of the finding") + Region: str = Field( + default="", description="AWS region where the finding was identified" + ) - @validator("Check_ID") + @field_validator("Check_ID") + @classmethod def validate_check_id(cls, v): """Validate that Check_ID follows the pattern XX-NN (e.g., SM-01, BR-14, AC-05)""" pattern = r"^[A-Z]{2,3}-\d{2}$" @@ -46,21 +50,24 @@ def validate_check_id(cls, v): ) return v - @validator("Reference") + @field_validator("Reference") + @classmethod def validate_reference_url(cls, v): """Validate that reference URL starts with https://""" if not str(v).startswith("https://"): raise ValueError("Reference URL must start with https://") return v - @validator("Severity") + @field_validator("Severity") + @classmethod def validate_severity(cls, v): """Validate that severity is one of the allowed values""" if v not in SeverityEnum.__members__.values(): raise ValueError("Severity must be one of the allowed values") return v - @validator("Status") + @field_validator("Status") + @classmethod def validate_status(cls, v): """Validate that status is one of the allowed values""" if v not in StatusEnum.__members__.values(): @@ -76,6 +83,7 @@ def create_finding( reference: str, severity: SeverityEnum, status: StatusEnum, + region: str = "", ) -> Dict[str, Any]: """ Create a validated finding object @@ -88,6 +96,7 @@ def create_finding( reference: Documentation URL severity: Severity level status: Current status + region: AWS region where the finding was identified Returns: Dict[str, Any]: Validated finding as dictionary @@ -103,5 +112,6 @@ def create_finding( Reference=reference, Severity=severity, Status=status, + Region=region, ) return dict(finding.model_dump()) # Convert to regular dictionary diff --git a/aiml-security-assessment/functions/security/bedrock_assessments/app.py b/aiml-security-assessment/functions/security/bedrock_assessments/app.py index be4ecd5..264dd42 100644 --- a/aiml-security-assessment/functions/security/bedrock_assessments/app.py +++ b/aiml-security-assessment/functions/security/bedrock_assessments/app.py @@ -7,7 +7,7 @@ from typing import Dict, List, Any, Optional from io import StringIO from botocore.config import Config -from botocore.exceptions import ClientError +from botocore.exceptions import ClientError, EndpointConnectionError import random import json from schema import create_finding @@ -25,6 +25,171 @@ logger = logging.getLogger() logger.setLevel(logging.ERROR) +# IAM is a global service. Findings derived purely from the IAM permission cache +# (e.g. BR-01, BR-03) are identical across regions, so they are produced only on +# the primary region (Map index 0) and tagged with this region label to avoid +# duplicate findings when scanning multiple regions. +GLOBAL_REGION_LABEL = "Global" + +# Error codes returned when a region exists but is not enabled/usable for the +# account (opt-in regions, disabled regions). The availability probe treats +# these the same as an endpoint connection failure. +REGION_UNAVAILABLE_ERROR_CODES = { + "UnrecognizedClientException", + "InvalidClientTokenId", + "AuthFailure", + "OptInRequired", +} + +ACCESS_DENIED_ERROR_CODES = { + "AccessDenied", + "AccessDeniedException", + "UnauthorizedOperation", +} + + +def describe_api_error(error: Exception, api_label: str, region: str = "") -> str: + """ + Build a report-friendly description for an API error raised by a regional + check. + + Some regions don't support a given Bedrock API. boto3 surfaces this as + "Unknown operation ..." (ValidationException) or UnknownOperationException. + For those, return a clean " not available in " message + instead of leaking the raw boto3 exception text into the report. Any other + error keeps its raw text so genuine problems (e.g. permissions) stay + diagnosable. + """ + error_text = str(error) + if "UnknownOperation" in error_text or "Unknown operation" in error_text: + location = region if region else "this region" + return f"{api_label} not available in {location}" + return f"Unable to check {api_label}: {error_text}" + + +def _probe_bedrock_resource_list(probe_label: str, probe_func) -> Optional[bool]: + """ + Probe a Bedrock list API and report whether it found any regional resources. + + Returns: + True if the API found at least one resource + False if the API was successfully queried and returned no resources + None if the result is inconclusive (for example, AccessDenied) + """ + try: + return bool(probe_func()) + except EndpointConnectionError: + raise + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "") + error_text = str(e) + + if code in REGION_UNAVAILABLE_ERROR_CODES: + raise + + if code in ACCESS_DENIED_ERROR_CODES: + logger.warning( + f"Unable to determine regional Bedrock footprint from {probe_label}: {code}" + ) + return None + + if ( + code == "ValidationException" + or "UnknownOperation" in error_text + or "Unknown operation" in error_text + ): + logger.info(f"{probe_label} API not available in this region") + return False + + logger.warning( + f"Unexpected error probing {probe_label} for regional Bedrock footprint: {error_text}" + ) + return None + except Exception as e: + error_text = str(e) + if "UnknownOperation" in error_text or "Unknown operation" in error_text: + logger.info(f"{probe_label} API not available in this region") + return False + + logger.warning( + f"Unexpected error probing {probe_label} for regional Bedrock footprint: {error_text}" + ) + return None + + +def _first_page_items(paginator, result_key: str) -> List[Dict[str, Any]]: + """Return at most the first page of items from a paginator-based Bedrock list API.""" + for page in paginator.paginate(PaginationConfig={"MaxItems": 1, "PageSize": 1}): + return page.get(result_key, []) + return [] + + +def detect_bedrock_regional_footprint(region: str = "") -> Optional[bool]: + """ + Detect whether a region has Bedrock-managed resources that justify regional findings. + + Returns: + True if Bedrock-managed resources exist in the region + False if supported APIs were probed and no resources were found + None if the footprint could not be determined confidently + """ + bedrock_client = boto3.client("bedrock", config=boto3_config, region_name=region) + bedrock_agent_client = boto3.client( + "bedrock-agent", config=boto3_config, region_name=region + ) + + probes = [ + ( + "Bedrock Guardrails", + lambda: bedrock_client.list_guardrails().get("guardrails", []), + ), + ( + "Bedrock Prompts", + lambda: bedrock_agent_client.list_prompts().get("promptSummaries", []), + ), + ( + "Bedrock Agents", + lambda: bedrock_agent_client.list_agents().get("agents", []), + ), + ( + "Bedrock Knowledge Bases", + lambda: _first_page_items( + bedrock_agent_client.get_paginator("list_knowledge_bases"), + "knowledgeBaseSummaries", + ), + ), + ( + "Bedrock Flows", + lambda: _first_page_items( + bedrock_agent_client.get_paginator("list_flows"), + "flowSummaries", + ), + ), + ( + "Bedrock Custom Models", + lambda: _first_page_items( + bedrock_client.get_paginator("list_custom_models"), + "modelSummaries", + ), + ), + ] + + indeterminate = False + successful_empty_probe = False + for probe_label, probe_func in probes: + probe_result = _probe_bedrock_resource_list(probe_label, probe_func) + if probe_result is True: + return True + if probe_result is False: + successful_empty_probe = True + if probe_result is None: + indeterminate = True + + if successful_empty_probe: + return False + + return None if indeterminate else False + def _extract_s3_bucket_name(s3_config: Optional[Dict[str, Any]]) -> Optional[str]: """Support both the documented field name and the legacy test fixture key.""" @@ -96,7 +261,9 @@ def get_permissions_cache(execution_id: str) -> Optional[Dict[str, Any]]: return None -def check_marketplace_subscription_access(permission_cache) -> Dict[str, Any]: +def check_marketplace_subscription_access( + permission_cache, region: str = "" +) -> Dict[str, Any]: logger.debug("Starting check for overly permissive Marketplace subscription access") try: findings = { @@ -182,6 +349,7 @@ def check_policy_for_subscription_access(policy_doc: Any) -> bool: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam-awsmanpol.html#security-iam-awsmanpol-bedrock-marketplace", severity="High", status="Failed", + region=region, ) ) else: @@ -197,6 +365,7 @@ def check_policy_for_subscription_access(policy_doc: Any) -> bool: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam-awsmanpol.html#security-iam-awsmanpol-bedrock-marketplace", severity="Medium", status="Passed", + region=region, ) ) @@ -219,6 +388,7 @@ def check_policy_for_subscription_access(policy_doc: Any) -> bool: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } @@ -282,7 +452,14 @@ def has_bedrock_access(iam_client, principal_name: str, principal_type: str) -> return False -def check_stale_bedrock_access(permission_cache) -> Dict[str, Any]: +def check_stale_bedrock_access(permission_cache, region: str = "") -> Dict[str, Any]: + """ + Check for stale Bedrock access using IAM service-last-accessed data. + + This check is derived purely from IAM (a global service) and the cached + permissions, so it produces identical results in every region. The handler + runs it once, on the primary region, tagged with GLOBAL_REGION_LABEL. + """ logger.debug("Starting check for stale Bedrock access") try: findings = { @@ -322,6 +499,7 @@ def check_stale_bedrock_access(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_last-accessed.html", severity="Informational", status="N/A", + region=region, ) ) return findings @@ -408,6 +586,7 @@ def check_stale_bedrock_access(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_last-accessed.html", severity="Medium", status="Failed", + region=region, ) ) @@ -435,6 +614,7 @@ def check_stale_bedrock_access(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_last-accessed.html", severity="Medium", status="Passed", + region=region, ) ) @@ -455,12 +635,15 @@ def check_stale_bedrock_access(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_full_access_roles(permission_cache) -> Dict[str, Any]: +def check_bedrock_full_access_roles( + permission_cache, region: str = "" +) -> Dict[str, Any]: """ Check for roles with AmazonBedrockFullAccess policy using cached permissions """ @@ -495,6 +678,7 @@ def check_bedrock_full_access_roles(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security_iam_id-based-policy-examples-agent.html#iam-agents-ex-all\nhttps://docs.aws.amazon.com/bedrock/latest/userguide/security_iam_id-based-policy-examples-br-studio.html", severity="High", status="Failed", + region=region, ) ) else: @@ -508,6 +692,7 @@ def check_bedrock_full_access_roles(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security_iam_id-based-policy-examples-agent.html#iam-agents-ex-all\nhttps://docs.aws.amazon.com/bedrock/latest/userguide/security_iam_id-based-policy-examples-br-studio.html", severity="High", status="Passed", + region=region, ) ) @@ -552,13 +737,13 @@ def get_role_usage(role_name: str) -> str: return result -def check_bedrock_vpc_endpoints() -> Dict[str, bool]: +def check_bedrock_vpc_endpoints(region: str = "") -> Dict[str, bool]: """ Check if any VPC has Bedrock VPC endpoints """ logger.debug("Checking for Bedrock VPC endpoints") try: - ec2_client = boto3.client("ec2", config=boto3_config) + ec2_client = boto3.client("ec2", config=boto3_config, region_name=region) bedrock_endpoints = [ "com.amazonaws.region.bedrock", @@ -568,8 +753,7 @@ def check_bedrock_vpc_endpoints() -> Dict[str, bool]: ] # Get current region - session = boto3.session.Session() - current_region = session.region_name + current_region = region logger.debug(f"Current region: {current_region}") # Get list of all VPCs @@ -678,7 +862,9 @@ def handle_aws_throttling(func, *args, **kwargs): raise -def check_bedrock_access_and_vpc_endpoints(permission_cache) -> Dict[str, Any]: +def check_bedrock_access_and_vpc_endpoints( + permission_cache, region: str = "" +) -> Dict[str, Any]: logger.debug("Starting check for Bedrock access and VPC endpoints") try: findings = { @@ -703,7 +889,25 @@ def check_bedrock_access_and_vpc_endpoints(permission_cache) -> Dict[str, Any]: break if bedrock_access_found: - vpc_endpoint_check = check_bedrock_vpc_endpoints() + bedrock_footprint_found = detect_bedrock_regional_footprint(region=region) + + if bedrock_footprint_found is False: + findings["details"] = "No regional Bedrock resources found" + findings["csv_data"].append( + create_finding( + check_id="BR-02", + finding_name="Amazon Bedrock private connectivity check", + finding_details="No regional Bedrock resources found to assess private connectivity", + resolution="No action required", + reference="https://docs.aws.amazon.com/bedrock/latest/userguide/vpc-interface-endpoints.html", + severity="Informational", + status="N/A", + region=region, + ) + ) + return findings + + vpc_endpoint_check = check_bedrock_vpc_endpoints(region=region) if not vpc_endpoint_check["has_endpoints"]: findings["status"] = "WARN" @@ -725,6 +929,7 @@ def check_bedrock_access_and_vpc_endpoints(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/vpc-interface-endpoints.html", severity="Medium", status="Failed", + region=region, ) ) else: @@ -745,6 +950,7 @@ def check_bedrock_access_and_vpc_endpoints(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/vpc-interface-endpoints.html", severity="High", status="Passed", + region=region, ) ) else: @@ -769,12 +975,13 @@ def check_bedrock_access_and_vpc_endpoints(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_guardrails() -> Dict[str, Any]: +def check_bedrock_guardrails(region: str = "") -> Dict[str, Any]: """ Check if Amazon Bedrock Guardrails are configured and being used """ @@ -787,7 +994,9 @@ def check_bedrock_guardrails() -> Dict[str, Any]: "csv_data": [], } - bedrock_client = boto3.client("bedrock", config=boto3_config) + bedrock_client = boto3.client( + "bedrock", config=boto3_config, region_name=region + ) try: # List all guardrails @@ -809,23 +1018,44 @@ def check_bedrock_guardrails() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html", severity="High", status="Passed", + region=region, ) ) else: - findings["status"] = "WARN" - findings["details"] = "No Bedrock guardrails configured" - findings["csv_data"].append( - create_finding( - check_id="BR-05", - finding_name="Bedrock Guardrails Check", - finding_details="No Amazon Bedrock Guardrails are configured. This may expose your application to potential risks such as harmful content, sensitive information disclosure, or hallucinations.", - resolution="Configure Bedrock Guardrails to implement safeguards such as:\n- Content filters to block harmful content\n- Denied topics to prevent undesirable discussions\n- Sensitive information filters to protect PII\n- Contextual grounding checks to prevent hallucinations", - reference="https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html", - severity="Medium", - status="Failed", - ) + bedrock_footprint_found = detect_bedrock_regional_footprint( + region=region ) + if bedrock_footprint_found is False: + findings["details"] = "No regional Bedrock resources found" + findings["csv_data"].append( + create_finding( + check_id="BR-05", + finding_name="Bedrock Guardrails Check", + finding_details="No regional Bedrock resources found to protect with guardrails", + resolution="No action required", + reference="https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html", + severity="Informational", + status="N/A", + region=region, + ) + ) + else: + findings["status"] = "WARN" + findings["details"] = "No Bedrock guardrails configured" + findings["csv_data"].append( + create_finding( + check_id="BR-05", + finding_name="Bedrock Guardrails Check", + finding_details="No Amazon Bedrock Guardrails are configured. This may expose your application to potential risks such as harmful content, sensitive information disclosure, or hallucinations.", + resolution="Configure Bedrock Guardrails to implement safeguards such as:\n- Content filters to block harmful content\n- Denied topics to prevent undesirable discussions\n- Sensitive information filters to protect PII\n- Contextual grounding checks to prevent hallucinations", + reference="https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html", + severity="Medium", + status="Failed", + region=region, + ) + ) + except bedrock_client.exceptions.ValidationException as e: findings["status"] = "ERROR" findings["details"] = f"Error validating guardrails configuration: {str(e)}" @@ -838,6 +1068,7 @@ def check_bedrock_guardrails() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html", severity="High", status="Failed", + region=region, ) ) @@ -858,12 +1089,13 @@ def check_bedrock_guardrails() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_logging_configuration() -> Dict[str, Any]: +def check_bedrock_logging_configuration(region: str = "") -> Dict[str, Any]: """ Check if model invocation logging is enabled for Amazon Bedrock """ @@ -871,7 +1103,7 @@ def check_bedrock_logging_configuration() -> Dict[str, Any]: # logging is enabled, the FinServ guide (PDF §1.2.1, §1.2.6, §1.2.7) # expects the log output to include guardrailTrace with action, # inputAssessments, and outputAssessments to support SR 11-7 audit trails - # and NYDFS 500.06 retention. See docs/SECURITY_CHECKS_FINSERV_PART3_APP_LAYER_AND_GAPS.md + # and NYDFS 500.06 retention. See docs/SECURITY_CHECKS_FINSERV.md # (FS-64 → BR-04 extension note) for the detection / remediation language. logger.debug("Starting check for Bedrock model invocation logging configuration") try: @@ -882,7 +1114,26 @@ def check_bedrock_logging_configuration() -> Dict[str, Any]: "csv_data": [], } - bedrock_client = boto3.client("bedrock", config=boto3_config) + bedrock_footprint_found = detect_bedrock_regional_footprint(region=region) + if bedrock_footprint_found is False: + findings["details"] = "No regional Bedrock resources found" + findings["csv_data"].append( + create_finding( + check_id="BR-04", + finding_name="Bedrock Model Invocation Logging Check", + finding_details="No regional Bedrock resources found to monitor with invocation logging", + resolution="No action required", + reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", + severity="Informational", + status="N/A", + region=region, + ) + ) + return findings + + bedrock_client = boto3.client( + "bedrock", config=boto3_config, region_name=region + ) try: # Get current logging configuration @@ -918,6 +1169,7 @@ def check_bedrock_logging_configuration() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", severity="Medium", status="Passed", + region=region, ) ) else: @@ -932,6 +1184,7 @@ def check_bedrock_logging_configuration() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", severity="Medium", status="Failed", + region=region, ) ) @@ -947,6 +1200,7 @@ def check_bedrock_logging_configuration() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", severity="Medium", status="Failed", + region=region, ) ) @@ -969,12 +1223,13 @@ def check_bedrock_logging_configuration() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_cloudtrail_logging() -> Dict[str, Any]: +def check_bedrock_cloudtrail_logging(region: str = "") -> Dict[str, Any]: """ Check if CloudTrail is configured to log Amazon Bedrock API calls """ @@ -982,7 +1237,7 @@ def check_bedrock_cloudtrail_logging() -> Dict[str, Any]: # Bedrock API calls, the FinServ guide (PDF §1.2.15) expects an advanced # event selector for AWS::Bedrock::KnowledgeBase so Retrieve and # RetrieveAndGenerate data events are captured (NOT logged by default). - # See docs/SECURITY_CHECKS_FINSERV_PART1_INFRA_CONTROLS.md (FS-23 → BR-06 + # See docs/SECURITY_CHECKS_FINSERV.md (FS-23 → BR-06 # extension note) for the detection / remediation language. logger.debug("Starting check for Bedrock CloudTrail logging configuration") try: @@ -993,7 +1248,26 @@ def check_bedrock_cloudtrail_logging() -> Dict[str, Any]: "csv_data": [], } - cloudtrail_client = boto3.client("cloudtrail", config=boto3_config) + bedrock_footprint_found = detect_bedrock_regional_footprint(region=region) + if bedrock_footprint_found is False: + findings["details"] = "No regional Bedrock resources found" + findings["csv_data"].append( + create_finding( + check_id="BR-06", + finding_name="Bedrock CloudTrail Logging Check", + finding_details="No regional Bedrock resources found to audit with Bedrock-specific CloudTrail coverage", + resolution="No action required", + reference="https://docs.aws.amazon.com/bedrock/latest/userguide/logging-using-cloudtrail.html", + severity="Informational", + status="N/A", + region=region, + ) + ) + return findings + + cloudtrail_client = boto3.client( + "cloudtrail", config=boto3_config, region_name=region + ) try: # Get all trails @@ -1063,6 +1337,7 @@ def check_bedrock_cloudtrail_logging() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/logging-using-cloudtrail.html", severity="Medium", status="Passed", + region=region, ) ) else: @@ -1081,6 +1356,7 @@ def check_bedrock_cloudtrail_logging() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/logging-using-cloudtrail.html", severity="High", status="Failed", + region=region, ) ) @@ -1096,6 +1372,7 @@ def check_bedrock_cloudtrail_logging() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/logging-using-cloudtrail.html", severity="High", status="Failed", + region=region, ) ) @@ -1118,12 +1395,13 @@ def check_bedrock_cloudtrail_logging() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_prompt_management() -> Dict[str, Any]: +def check_bedrock_prompt_management(region: str = "") -> Dict[str, Any]: """ Check if Amazon Bedrock Prompt Management feature is being used """ @@ -1136,7 +1414,9 @@ def check_bedrock_prompt_management() -> Dict[str, Any]: "csv_data": [], } - bedrock_client = boto3.client("bedrock-agent", config=boto3_config) + bedrock_client = boto3.client( + "bedrock-agent", config=boto3_config, region_name=region + ) try: # List all prompts @@ -1156,6 +1436,7 @@ def check_bedrock_prompt_management() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html", severity="Low", status="Passed", + region=region, ) ) @@ -1193,6 +1474,7 @@ def check_bedrock_prompt_management() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html", severity="Low", status="Failed", + region=region, ) ) else: @@ -1211,21 +1493,27 @@ def check_bedrock_prompt_management() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html", severity="Informational", status="N/A", + region=region, ) ) - except bedrock_client.exceptions.ValidationException as e: - findings["status"] = "ERROR" - findings["details"] = f"Error checking Prompt Management: {str(e)}" + except Exception as e: + # An API error (e.g. InternalServerErrorException after retries, + # throttling, or a permissions issue) is not a security failure. + # Surface it as N/A rather than Failed, matching the BR-11 pattern. + logger.warning(f"Error listing prompts: {str(e)}") findings["csv_data"].append( create_finding( check_id="BR-07", finding_name="Bedrock Prompt Management Check", - finding_details=f"Error checking Bedrock Prompt Management configuration: {str(e)}", - resolution="Verify your AWS credentials and permissions to access Bedrock Prompt Management.", + finding_details=describe_api_error( + e, "Bedrock Prompt Management API", region + ), + resolution="Verify your AWS credentials and permissions to access Bedrock Prompt Management, then retry the assessment.", reference="https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html", - severity="High", - status="Failed", + severity="Low", + status="N/A", + region=region, ) ) @@ -1248,12 +1536,13 @@ def check_bedrock_prompt_management() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_knowledge_base_encryption() -> Dict[str, Any]: +def check_bedrock_knowledge_base_encryption(region: str = "") -> Dict[str, Any]: """ Check if Amazon Bedrock Knowledge Bases have proper encryption configured including customer-managed KMS keys for data at rest @@ -1267,7 +1556,9 @@ def check_bedrock_knowledge_base_encryption() -> Dict[str, Any]: "csv_data": [], } - bedrock_agent_client = boto3.client("bedrock-agent", config=boto3_config) + bedrock_agent_client = boto3.client( + "bedrock-agent", config=boto3_config, region_name=region + ) try: # List all knowledge bases @@ -1287,6 +1578,7 @@ def check_bedrock_knowledge_base_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-kb.html", severity="Informational", status="N/A", + region=region, ) ) return findings @@ -1347,6 +1639,7 @@ def check_bedrock_knowledge_base_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-kb.html", severity="Informational", status="N/A", + region=region, ) ) @@ -1360,6 +1653,7 @@ def check_bedrock_knowledge_base_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-kb.html", severity="Informational", status="N/A", + region=region, ) ) else: @@ -1372,6 +1666,7 @@ def check_bedrock_knowledge_base_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-kb.html", severity="High", status="Passed", + region=region, ) ) @@ -1390,6 +1685,7 @@ def check_bedrock_knowledge_base_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-kb.html", severity="Informational", status="N/A", + region=region, ) ) else: @@ -1408,6 +1704,7 @@ def check_bedrock_knowledge_base_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-kb.html", severity="High", status="Failed", + region=region, ) ) else: @@ -1432,12 +1729,15 @@ def check_bedrock_knowledge_base_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_guardrail_iam_enforcement(permission_cache) -> Dict[str, Any]: +def check_bedrock_guardrail_iam_enforcement( + permission_cache, region: str = "" +) -> Dict[str, Any]: """ Check if IAM policies enforce the use of specific guardrails via the bedrock:GuardrailIdentifier condition key @@ -1451,7 +1751,9 @@ def check_bedrock_guardrail_iam_enforcement(permission_cache) -> Dict[str, Any]: "csv_data": [], } - bedrock_client = boto3.client("bedrock", config=boto3_config) + bedrock_client = boto3.client( + "bedrock", config=boto3_config, region_name=region + ) # First check if any guardrails exist try: @@ -1468,6 +1770,7 @@ def check_bedrock_guardrail_iam_enforcement(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-permissions-id.html", severity="Informational", status="N/A", + region=region, ) ) return findings @@ -1567,6 +1870,7 @@ def check_bedrock_guardrail_iam_enforcement(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-permissions-id.html", severity="High", status="Failed", + region=region, ) ) else: @@ -1581,6 +1885,7 @@ def check_bedrock_guardrail_iam_enforcement(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-permissions-id.html", severity="Informational", status="N/A", + region=region, ) ) else: @@ -1594,6 +1899,7 @@ def check_bedrock_guardrail_iam_enforcement(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-permissions-id.html", severity="Medium", status="Passed", + region=region, ) ) @@ -1616,12 +1922,13 @@ def check_bedrock_guardrail_iam_enforcement(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_custom_model_encryption() -> Dict[str, Any]: +def check_bedrock_custom_model_encryption(region: str = "") -> Dict[str, Any]: """ Check if custom/fine-tuned Bedrock models have proper encryption configured """ @@ -1634,7 +1941,9 @@ def check_bedrock_custom_model_encryption() -> Dict[str, Any]: "csv_data": [], } - bedrock_client = boto3.client("bedrock", config=boto3_config) + bedrock_client = boto3.client( + "bedrock", config=boto3_config, region_name=region + ) try: # List custom models @@ -1654,6 +1963,7 @@ def check_bedrock_custom_model_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-custom-job.html", severity="Informational", status="N/A", + region=region, ) ) return findings @@ -1721,6 +2031,7 @@ def check_bedrock_custom_model_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-custom-job.html", severity="Medium", status="Failed", + region=region, ) ) else: @@ -1733,6 +2044,7 @@ def check_bedrock_custom_model_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-custom-job.html", severity="High", status="Passed", + region=region, ) ) @@ -1742,11 +2054,12 @@ def check_bedrock_custom_model_encryption() -> Dict[str, Any]: create_finding( check_id="BR-11", finding_name="Bedrock Custom Model Encryption Check", - finding_details=f"Unable to list custom models: {str(e)}", + finding_details=describe_api_error(e, "Custom model API", region), resolution="Verify permissions to access Bedrock custom models", reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-customization-iam-role.html", severity="Low", status="N/A", + region=region, ) ) @@ -1769,12 +2082,13 @@ def check_bedrock_custom_model_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_invocation_log_encryption() -> Dict[str, Any]: +def check_bedrock_invocation_log_encryption(region: str = "") -> Dict[str, Any]: """ Check if S3 buckets used for model invocation logging have proper encryption """ @@ -1787,8 +2101,10 @@ def check_bedrock_invocation_log_encryption() -> Dict[str, Any]: "csv_data": [], } - bedrock_client = boto3.client("bedrock", config=boto3_config) - s3_client = boto3.client("s3", config=boto3_config) + bedrock_client = boto3.client( + "bedrock", config=boto3_config, region_name=region + ) + s3_client = boto3.client("s3", config=boto3_config, region_name=region) try: # Get logging configuration @@ -1809,6 +2125,7 @@ def check_bedrock_invocation_log_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", severity="Informational", status="N/A", + region=region, ) ) return findings @@ -1850,6 +2167,7 @@ def check_bedrock_invocation_log_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", severity="Medium", status="Passed", + region=region, ) ) else: @@ -1863,6 +2181,7 @@ def check_bedrock_invocation_log_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", severity="Medium", status="Failed", + region=region, ) ) @@ -1881,6 +2200,7 @@ def check_bedrock_invocation_log_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", severity="High", status="Failed", + region=region, ) ) elif _is_access_denied_client_error(e): @@ -1897,6 +2217,7 @@ def check_bedrock_invocation_log_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", severity="Informational", status="N/A", + region=region, ) ) else: @@ -1912,6 +2233,7 @@ def check_bedrock_invocation_log_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html", severity="Informational", status="N/A", + region=region, ) ) @@ -1934,12 +2256,13 @@ def check_bedrock_invocation_log_encryption() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_flows_guardrails() -> Dict[str, Any]: +def check_bedrock_flows_guardrails(region: str = "") -> Dict[str, Any]: """ Check if Bedrock Flows have guardrails configured on prompt and knowledge base nodes """ @@ -1952,7 +2275,9 @@ def check_bedrock_flows_guardrails() -> Dict[str, Any]: "csv_data": [], } - bedrock_agent_client = boto3.client("bedrock-agent", config=boto3_config) + bedrock_agent_client = boto3.client( + "bedrock-agent", config=boto3_config, region_name=region + ) try: # List all flows @@ -1972,6 +2297,7 @@ def check_bedrock_flows_guardrails() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/flows-guardrails.html", severity="Informational", status="N/A", + region=region, ) ) return findings @@ -2056,6 +2382,7 @@ def check_bedrock_flows_guardrails() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/flows-guardrails.html", severity="High", status="Failed", + region=region, ) ) else: @@ -2070,6 +2397,7 @@ def check_bedrock_flows_guardrails() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/flows-guardrails.html", severity="Medium", status="Passed", + region=region, ) ) else: @@ -2083,6 +2411,7 @@ def check_bedrock_flows_guardrails() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/flows-guardrails.html", severity="Informational", status="N/A", + region=region, ) ) @@ -2092,11 +2421,12 @@ def check_bedrock_flows_guardrails() -> Dict[str, Any]: create_finding( check_id="BR-13", finding_name="Bedrock Flows Guardrails Check", - finding_details=f"Unable to check flows: {str(e)}", + finding_details=describe_api_error(e, "Bedrock Flows API", region), resolution="Verify permissions to access Bedrock Flows", reference="https://docs.aws.amazon.com/bedrock/latest/userguide/flows-guardrails.html", severity="Low", status="N/A", + region=region, ) ) @@ -2119,12 +2449,13 @@ def check_bedrock_flows_guardrails() -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } -def check_bedrock_agent_roles(permission_cache) -> Dict[str, Any]: +def check_bedrock_agent_roles(permission_cache, region: str = "") -> Dict[str, Any]: """ Check IAM roles associated with Bedrock agents for least privilege access """ @@ -2137,7 +2468,9 @@ def check_bedrock_agent_roles(permission_cache) -> Dict[str, Any]: "csv_data": [], } - bedrock_client = boto3.client("bedrock-agent", config=boto3_config) + bedrock_client = boto3.client( + "bedrock-agent", config=boto3_config, region_name=region + ) try: # Get all Bedrock agents @@ -2157,6 +2490,7 @@ def check_bedrock_agent_roles(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security_iam_service-with-iam.html", severity="Informational", status="N/A", + region=region, ) ) return findings @@ -2264,6 +2598,7 @@ def check_bedrock_agent_roles(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/wellarchitected/latest/generative-ai-lens/gensec05-bp01.html", severity="High", status="Failed", + region=region, ) ) else: @@ -2279,6 +2614,7 @@ def check_bedrock_agent_roles(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/wellarchitected/latest/generative-ai-lens/gensec05-bp01.html", severity="Medium", status="Passed", + region=region, ) ) @@ -2294,6 +2630,7 @@ def check_bedrock_agent_roles(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/wellarchitected/latest/generative-ai-lens/gensec05-bp01.html", severity="High", status="Failed", + region=region, ) ) @@ -2314,6 +2651,7 @@ def check_bedrock_agent_roles(permission_cache) -> Dict[str, Any]: reference="https://docs.aws.amazon.com/bedrock/latest/userguide/security.html", severity="High", status="Failed", + region=region, ) ], } @@ -2333,6 +2671,7 @@ def generate_csv_report(findings: List[Dict[str, Any]]) -> str: "Reference", "Severity", "Status", + "Region", ] writer = csv.DictWriter(csv_buffer, fieldnames=fieldnames) @@ -2349,14 +2688,19 @@ def get_current_utc_date(): return datetime.now(timezone.utc).strftime("%Y/%m/%d") -def write_to_s3(execution_id, csv_content: str, bucket_name: str) -> str: +def write_to_s3( + execution_id, csv_content: str, bucket_name: str, region: str = "" +) -> str: """ Write CSV report to S3 bucket """ logger.debug(f"Writing CSV report to S3 bucket: {bucket_name}") try: s3_client = boto3.client("s3", config=boto3_config) - file_name = f"bedrock_security_report_{execution_id}.csv" + if region: + file_name = f"bedrock_security_report_{execution_id}_{region}.csv" + else: + file_name = f"bedrock_security_report_{execution_id}.csv" s3_client.put_object( Bucket=bucket_name, Key=file_name, Body=csv_content, ContentType="text/csv" @@ -2378,9 +2722,16 @@ def lambda_handler(event, context): all_findings = [] try: - # Initialize permission cache - logger.info("Initializing IAM permission cache") + # Extract target region from Step Functions Map state + region = event.get("Region", os.environ.get("AWS_REGION", "us-east-1")) + # IAM is global: only the primary region (Map index 0) runs IAM-only checks. + is_primary_region = int(event.get("RegionIndex", 0)) == 0 + logger.info(f"Scanning region: {region} (primary={is_primary_region})") + execution_id = event["Execution"]["Name"] + + # Initialize permission cache (shared/global IAM data) + logger.info("Initializing IAM permission cache") permission_cache = get_permissions_cache(execution_id) if not permission_cache: @@ -2389,67 +2740,143 @@ def lambda_handler(event, context): ) permission_cache = {"role_permissions": {}, "user_permissions": {}} - # Run all checks using the cached permissions - logger.info("Running AmazonBedrockFullAccess check") - bedrock_full_access_findings = check_bedrock_full_access_roles(permission_cache) - all_findings.append(bedrock_full_access_findings) + # Run global IAM-only checks once (on the primary region) so the same role + # violations are not reported once per scanned region. These run before the + # regional availability gate so they are still emitted even if Bedrock is + # not available in the primary region. + if is_primary_region: + logger.info("Running global AmazonBedrockFullAccess check (BR-01)") + all_findings.append( + check_bedrock_full_access_roles( + permission_cache, region=GLOBAL_REGION_LABEL + ) + ) + + logger.info("Running global marketplace subscription access check (BR-03)") + all_findings.append( + check_marketplace_subscription_access( + permission_cache, region=GLOBAL_REGION_LABEL + ) + ) + + # logger.info("Running global stale Bedrock access check (BR-14)") + # all_findings.append( + # check_stale_bedrock_access(permission_cache, region=GLOBAL_REGION_LABEL) + # ) + + # Verify Bedrock is available in this region. A ValidationException here + # (logging simply not configured) still means the service is reachable, + # so only an endpoint connection failure or a region-not-enabled error + # should short-circuit the assessment. + bedrock_unavailable = False + unavailable_detail = "" + try: + test_client = boto3.client( + "bedrock", config=boto3_config, region_name=region + ) + test_client.get_model_invocation_logging_configuration() + except EndpointConnectionError: + bedrock_unavailable = True + unavailable_detail = f"Amazon Bedrock is not available in region {region}. No checks performed." + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code", "") + if error_code in REGION_UNAVAILABLE_ERROR_CODES: + bedrock_unavailable = True + unavailable_detail = f"Amazon Bedrock is not available or not enabled in region {region} ({error_code}). No checks performed." + else: + # Service is reachable (e.g. ValidationException, AccessDenied) — + # proceed; individual checks handle their own errors. + logger.info( + f"Bedrock availability probe returned {error_code}; proceeding with checks" + ) + if bedrock_unavailable: + logger.info(f"Bedrock service not available in region {region}, skipping") + all_findings.append( + { + "check_name": "Bedrock Service Availability", + "status": "N/A", + "details": f"Bedrock is not available in region {region}", + "csv_data": [ + create_finding( + check_id="BR-00", + finding_name="Bedrock Service Availability", + finding_details=unavailable_detail, + resolution="No action required. Bedrock is not deployed in this region.", + reference="https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html", + severity="Informational", + status="N/A", + region=region, + ) + ], + } + ) + csv_content = generate_csv_report(all_findings) + bucket_name = os.environ.get("AIML_ASSESSMENT_BUCKET_NAME") + s3_url = write_to_s3(execution_id, csv_content, bucket_name, region=region) + return { + "statusCode": 200, + "body": { + "message": f"Bedrock not available in {region}", + "report_url": s3_url, + }, + } + + # Run regional checks using the cached permissions logger.info("Running Bedrock access and VPC endpoints check") bedrock_access_vpc_findings = check_bedrock_access_and_vpc_endpoints( - permission_cache + permission_cache, region=region ) all_findings.append(bedrock_access_vpc_findings) - # logger.info("Running stale access check") - # stale_access_findings = check_stale_bedrock_access(permission_cache) - # all_findings.append(stale_access_findings) - - logger.info("Running marketplace subscription access check") - marketplace_access_findings = check_marketplace_subscription_access( - permission_cache - ) - all_findings.append(marketplace_access_findings) - logger.info("Running Bedrock logging findings check") - bedrock_logging_findings = check_bedrock_logging_configuration() + bedrock_logging_findings = check_bedrock_logging_configuration(region=region) all_findings.append(bedrock_logging_findings) logger.info("Running Bedrock Guardrails check") - bedrock_guardrails_findings = check_bedrock_guardrails() + bedrock_guardrails_findings = check_bedrock_guardrails(region=region) all_findings.append(bedrock_guardrails_findings) logger.info("Running Bedrock CloudTrail logging check") - bedrock_cloudtrail_findings = check_bedrock_cloudtrail_logging() + bedrock_cloudtrail_findings = check_bedrock_cloudtrail_logging(region=region) all_findings.append(bedrock_cloudtrail_findings) logger.info("Running Bedrock Prompt Management check") - bedrock_prompt_management_findings = check_bedrock_prompt_management() + bedrock_prompt_management_findings = check_bedrock_prompt_management( + region=region + ) all_findings.append(bedrock_prompt_management_findings) logger.info("Running Bedrock agent IAM roles check") - bedrock_agent_roles_findings = check_bedrock_agent_roles(permission_cache) + bedrock_agent_roles_findings = check_bedrock_agent_roles( + permission_cache, region=region + ) all_findings.append(bedrock_agent_roles_findings) logger.info("Running Bedrock Knowledge Base encryption check") - kb_encryption_findings = check_bedrock_knowledge_base_encryption() + kb_encryption_findings = check_bedrock_knowledge_base_encryption(region=region) all_findings.append(kb_encryption_findings) logger.info("Running Bedrock Guardrail IAM enforcement check") guardrail_iam_findings = check_bedrock_guardrail_iam_enforcement( - permission_cache + permission_cache, region=region ) all_findings.append(guardrail_iam_findings) logger.info("Running Bedrock custom model encryption check") - custom_model_encryption_findings = check_bedrock_custom_model_encryption() + custom_model_encryption_findings = check_bedrock_custom_model_encryption( + region=region + ) all_findings.append(custom_model_encryption_findings) logger.info("Running Bedrock invocation log encryption check") - invocation_log_encryption_findings = check_bedrock_invocation_log_encryption() + invocation_log_encryption_findings = check_bedrock_invocation_log_encryption( + region=region + ) all_findings.append(invocation_log_encryption_findings) logger.info("Running Bedrock Flows guardrails check") - flows_guardrails_findings = check_bedrock_flows_guardrails() + flows_guardrails_findings = check_bedrock_flows_guardrails(region=region) all_findings.append(flows_guardrails_findings) # Generate and upload report @@ -2463,7 +2890,7 @@ def lambda_handler(event, context): ) logger.info("Writing report to S3") - s3_url = write_to_s3(execution_id, csv_content, bucket_name) + s3_url = write_to_s3(execution_id, csv_content, bucket_name, region=region) return { "statusCode": 200, diff --git a/aiml-security-assessment/functions/security/bedrock_assessments/schema.py b/aiml-security-assessment/functions/security/bedrock_assessments/schema.py index 6c5a9f5..16a66d9 100644 --- a/aiml-security-assessment/functions/security/bedrock_assessments/schema.py +++ b/aiml-security-assessment/functions/security/bedrock_assessments/schema.py @@ -1,6 +1,6 @@ from enum import Enum -from typing import Dict, Any -from pydantic import BaseModel, Field, validator +from typing import Any, Dict +from pydantic import BaseModel, Field, field_validator import re @@ -35,8 +35,12 @@ class Finding(BaseModel): Reference: str = Field(..., description="Documentation reference URL") Severity: SeverityEnum = Field(..., description="Severity level of the finding") Status: StatusEnum = Field(..., description="Current status of the finding") + Region: str = Field( + default="", description="AWS region where the finding was identified" + ) - @validator("Check_ID") + @field_validator("Check_ID") + @classmethod def validate_check_id(cls, v): """Validate that Check_ID follows the pattern XX-NN (e.g., SM-01, BR-14, AC-05)""" pattern = r"^[A-Z]{2,3}-\d{2}$" @@ -46,21 +50,24 @@ def validate_check_id(cls, v): ) return v - @validator("Reference") + @field_validator("Reference") + @classmethod def validate_reference_url(cls, v): """Validate that reference URL starts with https://""" if not str(v).startswith("https://"): raise ValueError("Reference URL must start with https://") return v - @validator("Severity") + @field_validator("Severity") + @classmethod def validate_severity(cls, v): """Validate that severity is one of the allowed values""" if v not in SeverityEnum.__members__.values(): raise ValueError("Severity must be one of the allowed values") return v - @validator("Status") + @field_validator("Status") + @classmethod def validate_status(cls, v): """Validate that status is one of the allowed values""" if v not in StatusEnum.__members__.values(): @@ -76,6 +83,7 @@ def create_finding( reference: str, severity: SeverityEnum, status: StatusEnum, + region: str = "", ) -> Dict[str, Any]: """ Create a validated finding object @@ -88,6 +96,7 @@ def create_finding( reference: Documentation URL severity: Severity level status: Current status + region: AWS region where the finding was identified Returns: Dict[str, Any]: Validated finding as dictionary @@ -103,5 +112,6 @@ def create_finding( Reference=reference, Severity=severity, Status=status, + Region=region, ) return dict(finding.model_dump()) # Convert to regular dictionary diff --git a/aiml-security-assessment/functions/security/finserv_assessments/app.py b/aiml-security-assessment/functions/security/finserv_assessments/app.py index 936795a..abfcd39 100644 --- a/aiml-security-assessment/functions/security/finserv_assessments/app.py +++ b/aiml-security-assessment/functions/security/finserv_assessments/app.py @@ -57,13 +57,18 @@ import json import logging import os +import re from dataclasses import dataclass from datetime import datetime, timezone from io import StringIO from typing import Any, Dict, List, Optional from botocore.config import Config -from botocore.exceptions import ClientError, ParamValidationError +from botocore.exceptions import ( + ClientError, + EndpointConnectionError, + ParamValidationError, +) from schema import create_finding @@ -243,6 +248,10 @@ def _is_missing_bucket_error(err: "ClientError") -> bool: # could not run (e.g., missing IAM permission). They are visible in the report # (Status="N/A") so a failed/permission-denied check does not silently vanish. COULD_NOT_ASSESS_PREFIX = "COULD NOT ASSESS: " +FINSERV_GUIDE_URL = ( + "https://d1.awsstatic.com/onedam/marketing-channels/website/public/" + "global-FinServ-ComplianceGuide-GenAIRisks-public.pdf" +) # --------------------------------------------------------------------------- # ResourceInventory data model and helpers (REQ-3, REQ-4.1, REQ-6.4) @@ -735,6 +744,23 @@ def _could_not_assess_row(check_id: str, check_name: str, err: Any) -> Dict[str, ) +def _no_regional_genai_resources_row(region: str) -> Dict[str, Any]: + """Visible N/A row used when a target region has no GenAI resource footprint.""" + return create_finding( + check_id="FS-00", + finding_name="FinServ Regional Scope Not Applicable", + finding_details=( + f"No regional Bedrock, AgentCore, or SageMaker resources were found in {region}; " + "FinServ GenAI risk checks were not applied to this region." + ), + resolution="No action required unless GenAI workloads are expected in this region.", + reference=FINSERV_GUIDE_URL, + severity="Informational", + status="N/A", + region=region, + ) + + # =========================================================================== # CATEGORY 1: UNBOUNDED CONSUMPTION (FS-01 to FS-06) # Risk: GenAI workloads can be exploited to exhaust compute/cost budgets @@ -2101,7 +2127,7 @@ def check_ecr_image_scanning() -> Dict[str, Any]: # # NOTE: FS-17 (Model Monitor Data Quality → SM-07), FS-18 (Model Drift Detection → SM-23), # and FS-19 (Model Registry Approval → SM-22) are merged into upstream checks. -# See extension notes in SECURITY_CHECKS_FINSERV_PART1_INFRA_CONTROLS.md. +# See extension notes in SECURITY_CHECKS_FINSERV.md. # =========================================================================== @@ -5395,7 +5421,7 @@ def check_foundation_model_lifecycle_policy() -> Dict[str, Any]: # Mitigations explicitly in the AWS FinServ Guide not covered by FS-01..63 # or the existing BR/SM/AC checks. # NOTE: FS-64 (Guardrail Trace Logging) is merged into upstream BR-04. -# See extension note in SECURITY_CHECKS_FINSERV_PART3_APP_LAYER_AND_GAPS.md. +# See extension note in SECURITY_CHECKS_FINSERV.md. # =========================================================================== @@ -6066,6 +6092,7 @@ def generate_csv_report(findings: List[Dict[str, Any]]) -> str: "Reference", "Severity", "Status", + "Region", "Compliance_Frameworks", ] writer = csv.DictWriter(csv_buffer, fieldnames=fieldnames) @@ -6076,6 +6103,216 @@ def generate_csv_report(findings: List[Dict[str, Any]]) -> str: return csv_buffer.getvalue() +def _normalized_target_regions(value: str) -> List[str]: + """Parse the CloudFormation TargetRegions parameter value.""" + value = (value or "").strip() + if not value or value.lower() == "all": + return [] + return [region.strip() for region in re.split(r"[,\s]+", value) if region.strip()] + + +def _get_region_scopes(event: Dict[str, Any]) -> List[str]: + """Return resolved target regions without assuming a fixed deployment region.""" + target_regions = event.get("TargetRegions") + if isinstance(target_regions, list): + regions = [ + str(region).strip() for region in target_regions if str(region).strip() + ] + if regions: + return regions + + regions = _normalized_target_regions(os.environ.get("TARGET_REGIONS", "")) + if regions: + return regions + + fallback_region = event.get("Region") or "" + return [fallback_region] if fallback_region else [] + + +def _probe_regional_resource_list(probe_label: str, probe_func) -> Optional[bool]: + """Return True for resources, False for a successful empty list, None if unknown.""" + try: + result = probe_func() + return bool(result) if isinstance(result, list) else None + except (EndpointConnectionError, ParamValidationError): + logger.info("%s API is not available in this region", probe_label) + return False + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code", "") + if _is_access_error(e): + logger.warning( + "Unable to determine FinServ regional footprint from %s: %s", + probe_label, + error_code, + ) + return None + if error_code in { + "UnknownOperationException", + "ValidationException", + "ResourceNotFoundException", + "OptInRequired", + "UnauthorizedOperation", + }: + logger.info( + "%s API is not available in this region: %s", probe_label, error_code + ) + return False + logger.warning( + "Unexpected error probing %s for FinServ regional footprint: %s", + probe_label, + error_code or str(e), + ) + return None + except Exception as e: + error_text = str(e) + if "Unknown operation" in error_text or "UnknownOperation" in error_text: + logger.info("%s API is not available in this region", probe_label) + return False + logger.warning( + "Unexpected error probing %s for FinServ regional footprint: %s", + probe_label, + error_text, + ) + return None + + +def detect_finserv_regional_footprint(region: str) -> Optional[bool]: + """ + Detect whether a target region has GenAI resources that justify regional + FinServ findings. + + Returns: + True when Bedrock, AgentCore, or SageMaker resources exist + False when supported probes succeed and return no resources + None when permissions/API errors prevent a confident decision + """ + bedrock = boto3.client("bedrock", config=boto3_config, region_name=region) + bedrock_agent = boto3.client( + "bedrock-agent", config=boto3_config, region_name=region + ) + agentcore = boto3.client( + "bedrock-agentcore-control", config=boto3_config, region_name=region + ) + sagemaker = boto3.client("sagemaker", config=boto3_config, region_name=region) + + probes = [ + ("Bedrock Guardrails", lambda: bedrock.list_guardrails().get("guardrails", [])), + ( + "Bedrock Agents", + lambda: bedrock_agent.list_agents().get("agentSummaries", []), + ), + ( + "Bedrock Knowledge Bases", + lambda: bedrock_agent.list_knowledge_bases().get( + "knowledgeBaseSummaries", [] + ), + ), + ( + "AgentCore Runtimes", + lambda: agentcore.list_agent_runtimes().get("agentRuntimes", []), + ), + ( + "SageMaker Endpoints", + lambda: sagemaker.list_endpoints(MaxResults=1).get("Endpoints", []), + ), + ( + "SageMaker Models", + lambda: sagemaker.list_models(MaxResults=1).get("Models", []), + ), + ( + "SageMaker Feature Groups", + lambda: sagemaker.list_feature_groups(MaxResults=1).get( + "FeatureGroupSummaries", [] + ), + ), + ] + + indeterminate = False + successful_empty_probe = False + for probe_label, probe_func in probes: + probe_result = _probe_regional_resource_list(probe_label, probe_func) + if probe_result is True: + return True + if probe_result is False: + successful_empty_probe = True + if probe_result is None: + indeterminate = True + + if successful_empty_probe: + return False + return None if indeterminate else False + + +def _partition_regions_by_finserv_footprint( + regions: List[str], +) -> "tuple[List[str], List[str]]": + """Split target regions into regions to assess and regions that are N/A.""" + assessable_regions = [] + empty_regions = [] + for region in regions: + footprint_found = detect_finserv_regional_footprint(region) + if footprint_found is False: + empty_regions.append(region) + else: + # Unknown footprint keeps the region in scope so access/API problems do + # not hide potentially real risks. + assessable_regions.append(region) + return assessable_regions, empty_regions + + +def _stamp_regions(findings: List[Dict[str, Any]], regions: List[str]) -> None: + """Expand missing CSV Region values so each target region is filterable.""" + regions = [region for region in regions if region] + if not regions: + return + + for finding in findings: + expanded_rows = [] + for row in finding.get("csv_data", []): + if row.get("Region"): + expanded_rows.append(row) + continue + for region in regions: + regional_row = dict(row) + regional_row["Region"] = region + expanded_rows.append(regional_row) + finding["csv_data"] = expanded_rows + + +def _append_no_resource_region_findings( + findings: List[Dict[str, Any]], regions: List[str] +) -> None: + """Append one informational N/A finding for each region without GenAI resources.""" + if not regions: + return + findings.append( + { + "check_name": "FinServ Regional Resource Scope", + "status": "PASS", + "details": "No regional GenAI resources found", + "csv_data": [ + _no_regional_genai_resources_row(region) for region in regions if region + ], + } + ) + + +def _apply_region_scope(findings: List[Dict[str, Any]], regions: List[str]) -> None: + """Scope FinServ rows to target regions with resources and emit N/A rows for empty regions.""" + if not regions: + return + + assessable_regions, empty_regions = _partition_regions_by_finserv_footprint(regions) + if assessable_regions: + _stamp_regions(findings, assessable_regions) + else: + for finding in findings: + finding["csv_data"] = [ + row for row in finding.get("csv_data", []) if row.get("Region") + ] + _append_no_resource_region_findings(findings, empty_regions) + + def write_to_s3(execution_id: str, csv_content: str, bucket_name: str) -> str: """Write CSV report to S3 bucket.""" s3_client = boto3.client("s3", config=boto3_config) @@ -6402,6 +6639,7 @@ def lambda_handler(event, context): """ logger.info("Starting FinServ GenAI security assessment") all_findings = [] + region_scopes = _get_region_scopes(event) execution_id = event.get("Execution", {}).get("Name", "local-test") permission_cache = get_permissions_cache(execution_id) or { @@ -6432,7 +6670,10 @@ def lambda_handler(event, context): ) all_findings.append(result) - # Generate and upload report + # Generate and upload report. Only duplicate regional FinServ rows into + # target regions where a GenAI footprint is present; empty regions receive a + # visible N/A row instead of false failed controls. + _apply_region_scope(all_findings, region_scopes) csv_content = generate_csv_report(all_findings) bucket_name = os.environ.get("AIML_ASSESSMENT_BUCKET_NAME") if not bucket_name: diff --git a/aiml-security-assessment/functions/security/finserv_assessments/schema.py b/aiml-security-assessment/functions/security/finserv_assessments/schema.py index 657ad66..22a4ed7 100644 --- a/aiml-security-assessment/functions/security/finserv_assessments/schema.py +++ b/aiml-security-assessment/functions/security/finserv_assessments/schema.py @@ -40,6 +40,9 @@ class Finding(BaseModel): Reference: str = Field(..., description="Documentation reference URL") Severity: SeverityEnum = Field(..., description="Severity level of the finding") Status: StatusEnum = Field(..., description="Current status of the finding") + Region: str = Field( + default="", description="AWS region where the finding was identified" + ) Compliance_Frameworks: str = Field( default="", description=( @@ -76,6 +79,7 @@ def create_finding( reference: str, severity: SeverityEnum, status: StatusEnum, + region: str = "", compliance_frameworks: Optional[str] = "", ) -> Dict[str, Any]: """Create a validated finding dict. @@ -94,6 +98,7 @@ def create_finding( Reference=reference, Severity=severity, Status=status, + Region=region, Compliance_Frameworks=compliance_frameworks or "", ) return dict(finding.model_dump()) diff --git a/aiml-security-assessment/functions/security/finserv_tests/test_checks.py b/aiml-security-assessment/functions/security/finserv_tests/test_checks.py index a5a7f92..bd5fb37 100644 --- a/aiml-security-assessment/functions/security/finserv_tests/test_checks.py +++ b/aiml-security-assessment/functions/security/finserv_tests/test_checks.py @@ -3497,6 +3497,101 @@ def test_no_exact_pins_on_aws_sdk(self): ) +class TestFinservRegionalFootprint: + """Regional footprint gating used to avoid false FinServ failures.""" + + @staticmethod + def _client_factory(clients): + def factory(service_name, **kwargs): + return clients[service_name] + + return factory + + @patch("finserv_app.boto3.client") + def test_detect_returns_true_when_any_genai_resource_exists(self, mock_client): + bedrock = MagicMock() + bedrock.list_guardrails.return_value = {"guardrails": [{"id": "gr-1"}]} + clients = { + "bedrock": bedrock, + "bedrock-agent": MagicMock(), + "bedrock-agentcore-control": MagicMock(), + "sagemaker": MagicMock(), + } + mock_client.side_effect = self._client_factory(clients) + + assert app.detect_finserv_regional_footprint("us-east-1") is True + mock_client.assert_any_call( + "bedrock", config=app.boto3_config, region_name="us-east-1" + ) + + @patch("finserv_app.boto3.client") + def test_detect_returns_false_when_all_supported_probes_are_empty( + self, mock_client + ): + bedrock = MagicMock() + bedrock.list_guardrails.return_value = {"guardrails": []} + bedrock_agent = MagicMock() + bedrock_agent.list_agents.return_value = {"agentSummaries": []} + bedrock_agent.list_knowledge_bases.return_value = {"knowledgeBaseSummaries": []} + agentcore = MagicMock() + agentcore.list_agent_runtimes.return_value = {"agentRuntimes": []} + sagemaker = MagicMock() + sagemaker.list_endpoints.return_value = {"Endpoints": []} + sagemaker.list_models.return_value = {"Models": []} + sagemaker.list_feature_groups.return_value = {"FeatureGroupSummaries": []} + mock_client.side_effect = self._client_factory( + { + "bedrock": bedrock, + "bedrock-agent": bedrock_agent, + "bedrock-agentcore-control": agentcore, + "sagemaker": sagemaker, + } + ) + + assert app.detect_finserv_regional_footprint("us-west-2") is False + + @patch("finserv_app.boto3.client") + def test_detect_returns_none_when_footprint_is_indeterminate(self, mock_client): + bedrock = MagicMock() + bedrock.list_guardrails.side_effect = _client_error("AccessDeniedException") + bedrock_agent = MagicMock() + bedrock_agent.list_agents.side_effect = _client_error("AccessDeniedException") + bedrock_agent.list_knowledge_bases.side_effect = _client_error( + "AccessDeniedException" + ) + agentcore = MagicMock() + agentcore.list_agent_runtimes.side_effect = _client_error( + "AccessDeniedException" + ) + sagemaker = MagicMock() + sagemaker.list_endpoints.side_effect = _client_error("AccessDeniedException") + sagemaker.list_models.side_effect = _client_error("AccessDeniedException") + sagemaker.list_feature_groups.side_effect = _client_error( + "AccessDeniedException" + ) + mock_client.side_effect = self._client_factory( + { + "bedrock": bedrock, + "bedrock-agent": bedrock_agent, + "bedrock-agentcore-control": agentcore, + "sagemaker": sagemaker, + } + ) + + assert app.detect_finserv_regional_footprint("eu-west-1") is None + + @patch("finserv_app.detect_finserv_regional_footprint") + def test_partition_keeps_indeterminate_regions_in_scope(self, mock_detect): + mock_detect.side_effect = [None, False, True] + + assessable, empty = app._partition_regions_by_finserv_footprint( + ["unknown-region", "empty-region", "active-region"] + ) + + assert assessable == ["unknown-region", "active-region"] + assert empty == ["empty-region"] + + class TestGenerateCsvReport: """Test CSV report generation.""" @@ -3505,6 +3600,7 @@ def test_empty_findings_produces_header_only(self): lines = csv_content.strip().split("\n") assert len(lines) == 1 # header only assert "Check_ID" in lines[0] + assert "Region" in lines[0] def test_findings_produce_csv_rows(self): findings = [ @@ -3529,6 +3625,131 @@ def test_findings_produce_csv_rows(self): assert len(lines) == 2 # header + 1 data row assert "FS-01" in lines[1] + def test_region_scopes_use_configured_target_regions_from_event(self): + event = {"Region": "fallback-region", "TargetRegions": ["region-a", "region-b"]} + + assert app._get_region_scopes(event) == ["region-a", "region-b"] + + def test_region_scopes_use_target_regions_env_when_event_list_absent( + self, monkeypatch + ): + monkeypatch.setenv("TARGET_REGIONS", "region-a,region-b") + + assert app._get_region_scopes({"Region": "fallback-region"}) == [ + "region-a", + "region-b", + ] + + def test_region_scopes_accept_whitespace_delimited_target_regions( + self, monkeypatch + ): + monkeypatch.setenv("TARGET_REGIONS", "region-a region-b") + + assert app._get_region_scopes({"Region": "fallback-region"}) == [ + "region-a", + "region-b", + ] + + def test_stamp_regions_expands_missing_csv_regions(self): + findings = [ + { + "check_name": "Test", + "status": "PASS", + "csv_data": [ + { + "Check_ID": "FS-01", + "Finding": "Test Finding", + "Finding_Details": "Details", + "Resolution": "Fix", + "Reference": "https://example.com", + "Severity": "High", + "Status": "Passed", + }, + { + "Check_ID": "FS-02", + "Finding": "Already Scoped", + "Finding_Details": "Details", + "Resolution": "Fix", + "Reference": "https://example.com", + "Severity": "Medium", + "Status": "Failed", + "Region": "Global", + }, + ], + } + ] + + app._stamp_regions(findings, ["region-a", "region-b"]) + + regions = [row["Region"] for row in findings[0]["csv_data"]] + assert regions == ["region-a", "region-b", "Global"] + + def test_apply_region_scope_does_not_copy_failures_to_empty_regions(self): + findings = [ + { + "check_name": "Test", + "status": "WARN", + "csv_data": [ + { + "Check_ID": "FS-01", + "Finding": "Test Failed Finding", + "Finding_Details": "Details", + "Resolution": "Fix", + "Reference": "https://example.com", + "Severity": "High", + "Status": "Failed", + } + ], + } + ] + + with patch( + "finserv_app._partition_regions_by_finserv_footprint", + return_value=(["region-with-resources"], ["region-without-resources"]), + ): + app._apply_region_scope( + findings, ["region-with-resources", "region-without-resources"] + ) + + rows = [row for finding in findings for row in finding["csv_data"]] + failed_rows = [row for row in rows if row["Status"] == "Failed"] + na_rows = [row for row in rows if row["Status"] == "N/A"] + + assert [row["Region"] for row in failed_rows] == ["region-with-resources"] + assert [row["Region"] for row in na_rows] == ["region-without-resources"] + assert na_rows[0]["Check_ID"] == "FS-00" + + def test_apply_region_scope_suppresses_unscoped_rows_when_all_regions_empty(self): + findings = [ + { + "check_name": "Test", + "status": "WARN", + "csv_data": [ + { + "Check_ID": "FS-01", + "Finding": "Test Failed Finding", + "Finding_Details": "Details", + "Resolution": "Fix", + "Reference": "https://example.com", + "Severity": "High", + "Status": "Failed", + } + ], + } + ] + + with patch( + "finserv_app._partition_regions_by_finserv_footprint", + return_value=([], ["region-a", "region-b"]), + ): + app._apply_region_scope(findings, ["region-a", "region-b"]) + + rows = [row for finding in findings for row in finding["csv_data"]] + + assert {row["Region"] for row in rows} == {"region-a", "region-b"} + assert {row["Status"] for row in rows} == {"N/A"} + assert {row["Check_ID"] for row in rows} == {"FS-00"} + def test_multiple_findings_multiple_rows(self): findings = [ { diff --git a/aiml-security-assessment/functions/security/finserv_tests/test_lambda_handler.py b/aiml-security-assessment/functions/security/finserv_tests/test_lambda_handler.py index bf4558a..264904e 100644 --- a/aiml-security-assessment/functions/security/finserv_tests/test_lambda_handler.py +++ b/aiml-security-assessment/functions/security/finserv_tests/test_lambda_handler.py @@ -274,6 +274,56 @@ def test_handler_passes_inventory_to_build_finserv_checks( "lambda_handler must never pass None as the inventory argument" ) + @patch("finserv_app.write_to_s3") + @patch("finserv_app.get_permissions_cache") + @patch("finserv_app._apply_region_scope") + @patch("finserv_app.build_finserv_checks") + @patch("finserv_app.collect_resource_inventory") + def test_handler_scopes_findings_with_target_regions_from_event( + self, + mock_collect, + mock_build, + mock_apply_scope, + mock_cache, + mock_s3, + ): + """lambda_handler uses the state-machine TargetRegions list for report scoping.""" + mock_collect.return_value = object() + mock_build.return_value = [ + ( + "FS-01", + lambda: { + "check_name": "Scoped Check", + "status": "WARN", + "csv_data": [ + { + "Check_ID": "FS-01", + "Finding": "Scoped Finding", + "Finding_Details": "Details", + "Resolution": "Fix", + "Reference": "https://example.com", + "Severity": "High", + "Status": "Failed", + } + ], + }, + ) + ] + mock_cache.return_value = {"role_permissions": {}, "user_permissions": {}} + mock_s3.return_value = "https://bucket.s3.amazonaws.com/report.csv" + event = { + "Execution": {"Name": "exec-target-regions"}, + "Region": "fallback-region", + "TargetRegions": ["region-a", "region-b"], + } + + app.lambda_handler(event, None) + + mock_apply_scope.assert_called_once() + findings_arg, regions_arg = mock_apply_scope.call_args.args + assert regions_arg == ["region-a", "region-b"] + assert findings_arg[0]["check_name"] == "Scoped Check" + class TestWriteToS3: """Test the write_to_s3 helper.""" diff --git a/aiml-security-assessment/functions/security/finserv_tests/test_schema.py b/aiml-security-assessment/functions/security/finserv_tests/test_schema.py index 7464790..6aacbea 100644 --- a/aiml-security-assessment/functions/security/finserv_tests/test_schema.py +++ b/aiml-security-assessment/functions/security/finserv_tests/test_schema.py @@ -98,10 +98,23 @@ def test_output_has_all_csv_fields(self): "Reference", "Severity", "Status", + "Region", "Compliance_Frameworks", } assert set(result.keys()) == expected_keys + def test_region_defaults_to_empty_string(self): + result = create_finding( + check_id="FS-42", + finding_name="Name", + finding_details="Details", + resolution="Resolution", + reference="https://example.com", + severity="High", + status="Failed", + ) + assert result["Region"] == "" + @pytest.mark.parametrize( "check_id", ["FS-01", "FS-69", "BR-14", "SM-07", "AC-05"], diff --git a/aiml-security-assessment/functions/security/generate_consolidated_report/app.py b/aiml-security-assessment/functions/security/generate_consolidated_report/app.py index ea707b9..f20fc9c 100644 --- a/aiml-security-assessment/functions/security/generate_consolidated_report/app.py +++ b/aiml-security-assessment/functions/security/generate_consolidated_report/app.py @@ -10,6 +10,13 @@ from report_template import generate_html_report as generate_report_from_template +# Sentinel region label used by the per-service assessments to tag findings that +# are derived purely from global (IAM) data and run once per execution rather +# than per region (e.g. BR-01, SM-02, AC-09). It is NOT a real AWS region, so it +# must be excluded when counting scanned regions for the report's multi-region +# UI (region filter, "Risk by Region", region count). +GLOBAL_REGION_LABEL = "Global" + boto3_config = Config( retries=dict( max_attempts=10, # Maximum number of retries @@ -56,37 +63,26 @@ def get_assessment_results(execution_id: str, account_id: str = None) -> Dict[st try: s3_client = boto3.client("s3", config=boto3_config) - # List all CSV files with execution ID in filename (bucket root) + # List all CSV files with execution ID in filename (bucket root). + # Use a paginator: a multi-region scan produces one file per service per + # region, so a single list_objects_v2 call (capped at 1000 keys) could + # silently truncate and drop regions for large scans. s3_bucket = os.environ.get("AIML_ASSESSMENT_BUCKET_NAME") - response = s3_client.list_objects_v2( - Bucket=s3_bucket, Prefix=f"bedrock_security_report_{execution_id}" - ) - - # Also check for SageMaker reports - sagemaker_response = s3_client.list_objects_v2( - Bucket=s3_bucket, Prefix=f"sagemaker_security_report_{execution_id}" - ) - - # Also check for AgentCore reports - agentcore_response = s3_client.list_objects_v2( - Bucket=s3_bucket, Prefix=f"agentcore_security_report_{execution_id}" - ) + paginator = s3_client.get_paginator("list_objects_v2") - # Also check for FinServ reports - finserv_response = s3_client.list_objects_v2( - Bucket=s3_bucket, Prefix=f"finserv_security_report_{execution_id}" - ) + # One prefix per service; each matches every region's report file. + prefixes = [ + f"bedrock_security_report_{execution_id}", + f"sagemaker_security_report_{execution_id}", + f"agentcore_security_report_{execution_id}", + f"finserv_security_report_{execution_id}", + ] - # Combine all responses all_objects = [] - if "Contents" in response: - all_objects.extend(response["Contents"]) - if "Contents" in sagemaker_response: - all_objects.extend(sagemaker_response["Contents"]) - if "Contents" in agentcore_response: - all_objects.extend(agentcore_response["Contents"]) - if "Contents" in finserv_response: - all_objects.extend(finserv_response["Contents"]) + for prefix in prefixes: + for page in paginator.paginate(Bucket=s3_bucket, Prefix=prefix): + all_objects.extend(page.get("Contents", [])) + if not all_objects: logger.warning(f"No assessment files found for execution {execution_id}") return {} @@ -211,11 +207,32 @@ def generate_html_report(assessment_results: Dict[str, Any]) -> str: "finserv": {"passed": 0, "failed": 0, "na": 0}, } service_findings = {"bedrock": [], "sagemaker": [], "agentcore": [], "finserv": []} + regions = set() + + # Global/IAM findings (Region == "Global", e.g. BR-01, SM-02, AC-09) are + # produced once per run by the primary-region Lambda and should land in a + # single CSV. Dedup defensively here so the totals and per-region tiles stay + # correct even if the same finding ever appears in more than one region's + # file (e.g. RegionIndex missing from the event, or a future per-region + # write of a global check). The key uniquely identifies a finding within an + # account; account is included so a future multi-account merge is unaffected. + seen_findings = set() for service in ["bedrock", "sagemaker", "agentcore", "finserv"]: if service in assessment_results: for report_type, findings in assessment_results[service].items(): for finding in findings: + dedup_key = ( + finding.get("Account_ID", ""), + service, + finding.get("Check_ID", ""), + finding.get("Region", ""), + finding.get("Finding_Details", ""), + ) + if dedup_key in seen_findings: + continue + seen_findings.add(dedup_key) + finding["_service"] = service all_findings.append(finding) service_findings[service].append(finding) @@ -226,6 +243,11 @@ def generate_html_report(assessment_results: Dict[str, Any]) -> str: service_stats[service]["failed"] += 1 elif status == "n/a": service_stats[service]["na"] += 1 + region = finding.get("Region", "") + # "Global" tags IAM-only findings; it is not a scanned region + # and must not inflate the region count / multi-region UI. + if region and region != GLOBAL_REGION_LABEL and "," not in region: + regions.add(region) account_id = assessment_results.get("account_id", "Unknown") timestamp = assessment_results.get( @@ -240,6 +262,7 @@ def generate_html_report(assessment_results: Dict[str, Any]) -> str: mode="single", account_id=account_id, timestamp=timestamp, + regions=sorted(regions) if regions else None, ) except Exception as e: logger.error(f"Error generating HTML report: {str(e)}", exc_info=True) @@ -250,6 +273,11 @@ def get_current_utc_date(): return datetime.now(timezone.utc).strftime("%Y/%m/%d") +def build_single_account_report_key(timestamp: str) -> str: + """Build the single-account HTML report object key.""" + return f"security_assessment_single_account_{timestamp}.html" + + def write_html_to_s3( html_content: str, s3_bucket: str, execution_id: str, account_id: str = None ) -> Optional[str]: @@ -269,7 +297,7 @@ def write_html_to_s3( # Generate the S3 key for local bucket (no account folder needed) timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - s3_key = f"security_assessment_{timestamp}_{execution_id}.html" + s3_key = build_single_account_report_key(timestamp) # Upload the HTML file s3_client.put_object( diff --git a/aiml-security-assessment/functions/security/generate_consolidated_report/report_template.py b/aiml-security-assessment/functions/security/generate_consolidated_report/report_template.py index 7cb6d41..27cf527 100644 --- a/aiml-security-assessment/functions/security/generate_consolidated_report/report_template.py +++ b/aiml-security-assessment/functions/security/generate_consolidated_report/report_template.py @@ -23,6 +23,14 @@ '' ) +GENAI_LENS_URL = ( + "https://docs.aws.amazon.com/wellarchitected/latest/generative-ai-lens/" + "generative-ai-lens.html" +) +FINSERV_GUIDE_URL = ( + "https://aws.amazon.com/blogs/security/" + "introducing-the-updated-aws-user-guide-to-governance-risk-and-compliance-for-responsible-ai-adoption/" +) def generate_table_rows(findings: List[Dict], include_data_attrs: bool = True) -> str: @@ -48,6 +56,7 @@ def generate_table_rows(findings: List[Dict], include_data_attrs: bool = True) - ) service = finding.get("_service", "bedrock") account_id = finding.get("account_id", finding.get("Account_ID", "")) + region = finding.get("region", finding.get("Region", "")) check_id = finding.get("check_id", finding.get("Check_ID", "")) finding_name = finding.get("finding", finding.get("Finding", "")) details = finding.get("details", finding.get("Finding_Details", "")) @@ -60,7 +69,7 @@ def generate_table_rows(findings: List[Dict], include_data_attrs: bool = True) - ref_html = '-' data_attrs = ( - f'data-service="{service}" data-severity="{severity}" data-status="{status}" data-account="{account_id}"' + f'data-service="{service}" data-severity="{severity}" data-status="{status}" data-account="{account_id}" data-region="{region}"' if include_data_attrs else "" ) @@ -72,6 +81,7 @@ def generate_table_rows(findings: List[Dict], include_data_attrs: bool = True) - row = f""" {account_id} + {region} {check_id} {finding_name} {details} @@ -85,7 +95,7 @@ def generate_table_rows(findings: List[Dict], include_data_attrs: bool = True) - return ( "\n".join(rows) if rows - else 'No findings to display' + else 'No findings to display' ) @@ -163,6 +173,11 @@ def get_html_template() -> str: .section-title .service-icon svg {{ border-radius: 8px; }} .nav-item .count {{ margin-left: auto; font-size: 12px; font-weight: 600; background: var(--surface-2); padding: 2px 8px; border-radius: 10px; }} .nav-item.active .count {{ background: var(--accent); color: #fff; }} + .nav-section.industry-nav {{ border-top: 1px solid var(--border); padding-top: 16px; margin-top: -8px; }} + .industry-nav .nav-item {{ background: var(--accent-soft); color: var(--text); box-shadow: inset 3px 0 0 var(--accent); }} + .industry-nav .nav-item:hover {{ background: var(--accent-soft); color: var(--accent); }} + .industry-nav .nav-item.active {{ background: var(--accent-soft); color: var(--accent); }} + .industry-nav .nav-item .count {{ background: var(--accent); color: #fff; }} .sidebar-footer {{ margin-top: auto; padding: 16px 20px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-3); }} .sidebar-footer a {{ color: var(--accent); text-decoration: none; }} .main {{ padding: 32px 40px; max-width: 1400px; }} @@ -179,6 +194,11 @@ def get_html_template() -> str: .metric.highlight .metric-value {{ color: var(--success); }} .metric.danger .metric-value {{ color: var(--danger); }} .metric.warning .metric-value {{ color: var(--warning); }} + .scope-industry {{ margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }} + .scope-industry-label {{ font-size: 11px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }} + .scope-chip-row {{ display: flex; gap: 12px; flex-wrap: wrap; }} + .scope-chip {{ display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--surface-2); border-radius: 6px; }} + .scope-chip.industry-chip {{ background: var(--accent-soft); border: 1px solid var(--accent); }} .card {{ background: var(--surface); border: 2px solid var(--border); border-radius: 12px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }} .card-header {{ padding: 16px 20px; border-bottom: 2px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--surface-2); }} .card-header h3 {{ font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 10px; }} @@ -195,15 +215,16 @@ def get_html_template() -> str: .alert-domain {{ font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} .alert-category {{ font-size: 12px; color: var(--text-2); margin-top: 2px; }} .table-wrap {{ overflow-x: auto; max-height: 900px; overflow-y: auto; }} - table {{ width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; min-width: 900px; }} - table th:nth-child(1) {{ width: 11%; }} - table th:nth-child(2) {{ width: 7%; }} - table th:nth-child(3) {{ width: 13%; }} - table th:nth-child(4) {{ width: 20%; }} - table th:nth-child(5) {{ width: 20%; }} - table th:nth-child(6) {{ width: 7%; }} - table th:nth-child(7) {{ width: 10%; }} - table th:nth-child(8) {{ width: 10%; }} + table {{ width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; min-width: 1000px; }} + table th:nth-child(1) {{ width: 10%; }} + table th:nth-child(2) {{ width: 9%; }} + table th:nth-child(3) {{ width: 6%; }} + table th:nth-child(4) {{ width: 12%; }} + table th:nth-child(5) {{ width: 18%; }} + table th:nth-child(6) {{ width: 18%; }} + table th:nth-child(7) {{ width: 6%; }} + table th:nth-child(8) {{ width: 9%; }} + table th:nth-child(9) {{ width: 9%; }} th {{ text-align: left; padding: 14px 16px; font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text); background: var(--surface-2); border-bottom: 3px solid var(--accent); white-space: nowrap; position: sticky; top: 0; }} th.sortable {{ cursor: pointer; user-select: none; transition: background 0.15s; }} th.sortable:hover {{ background: var(--border); }} @@ -293,14 +314,14 @@ def get_html_template() -> str: - AgentCore - {agentcore_total} - - {finserv_nav} - -
-
Security Checks
{security_checks}
Evaluated per account
+
Security Checks
{security_checks}
{security_checks_sub}
Total Findings
{total_findings}
{findings_sub}
Actionable Findings
{actionable_findings}
High, Medium, and Low severity
High Severity
{high_passed}/{high_count}
{high_pass_rate}% passed · Immediate action required
@@ -342,12 +363,13 @@ def get_html_template() -> str:
{account_filter} -
+ {region_filter} +
-
{all_rows}
Account IDCheck IDFindingDetailsResolutionReferenceSeverityStatus
+
{all_rows}
Account IDRegionCheck IDFindingDetailsResolutionReferenceSeverityStatus
Risk Distribution
@@ -359,6 +381,7 @@ def get_html_template() -> str:
Overall
{pass_rate}%
{passed_count} of {actionable_findings} actionable checks
{account_risk_section} + {region_risk_section}

Findings by Service

Bedrock
{bedrock_total}
{bedrock_failed} Failed · {bedrock_passed} Passed
@@ -372,33 +395,36 @@ def get_html_template() -> str:
{bedrock_account_filter} + {bedrock_region_filter}
-
{bedrock_rows}
Account IDCheck IDFindingDetailsResolutionReferenceSeverityStatus
+
{bedrock_rows}
Account IDRegionCheck IDFindingDetailsResolutionReferenceSeverityStatus
Amazon SageMaker Findings
{sagemaker_account_filter} + {sagemaker_region_filter}
-
{sagemaker_rows}
Account IDCheck IDFindingDetailsResolutionReferenceSeverityStatus
+
{sagemaker_rows}
Account IDRegionCheck IDFindingDetailsResolutionReferenceSeverityStatus
Amazon Bedrock AgentCore Findings
{agentcore_account_filter} + {agentcore_region_filter}
-
{agentcore_rows}
Account IDCheck IDFindingDetailsResolutionReferenceSeverityStatus
+
{agentcore_rows}
Account IDRegionCheck IDFindingDetailsResolutionReferenceSeverityStatus
{finserv_section}
@@ -435,6 +461,7 @@ def get_html_template() -> str: function applyFilters() {{ const searchText = document.getElementById('searchInput').value.toLowerCase(); const accountFilter = document.getElementById('accountFilter')?.value.toLowerCase() || ''; + const regionFilter = document.getElementById('regionFilter')?.value.toLowerCase() || ''; const serviceFilter = document.getElementById('serviceFilter').value.toLowerCase(); const severityFilter = document.getElementById('severityFilter').value.toLowerCase(); const statusFilter = document.getElementById('statusFilter').value.toLowerCase(); @@ -442,12 +469,14 @@ def get_html_template() -> str: rows.forEach(row => {{ const rowText = row.textContent.toLowerCase(); const rowAccount = row.dataset.account || ''; + const rowRegion = row.dataset.region || ''; const rowService = row.dataset.service || ''; const rowSeverity = row.dataset.severity || ''; const rowStatus = row.dataset.status || ''; let show = true; if (searchText && !rowText.includes(searchText)) show = false; if (accountFilter && rowAccount !== accountFilter) show = false; + if (regionFilter && rowRegion.toLowerCase() !== regionFilter) show = false; if (serviceFilter && rowService !== serviceFilter) show = false; if (severityFilter && rowSeverity !== severityFilter) show = false; if (statusFilter && rowStatus !== statusFilter) show = false; @@ -457,6 +486,7 @@ def get_html_template() -> str: document.getElementById('resetFilters').addEventListener('click', function() {{ document.getElementById('searchInput').value = ''; if (document.getElementById('accountFilter')) document.getElementById('accountFilter').value = ''; + if (document.getElementById('regionFilter')) document.getElementById('regionFilter').value = ''; document.getElementById('serviceFilter').value = ''; document.getElementById('severityFilter').value = ''; document.getElementById('statusFilter').value = ''; @@ -464,6 +494,7 @@ def get_html_template() -> str: }}); document.getElementById('searchInput').addEventListener('input', applyFilters); if (document.getElementById('accountFilter')) document.getElementById('accountFilter').addEventListener('change', applyFilters); + if (document.getElementById('regionFilter')) document.getElementById('regionFilter').addEventListener('change', applyFilters); document.getElementById('serviceFilter').addEventListener('change', applyFilters); document.getElementById('severityFilter').addEventListener('change', applyFilters); document.getElementById('statusFilter').addEventListener('change', applyFilters); @@ -504,9 +535,13 @@ def get_html_template() -> str: aVal = a.dataset.account || ''; bVal = b.dataset.account || ''; break; + case 'region': + aVal = a.dataset.region || ''; + bVal = b.dataset.region || ''; + break; case 'checkId': - aVal = a.querySelector('td:nth-child(2) code')?.textContent || ''; - bVal = b.querySelector('td:nth-child(2) code')?.textContent || ''; + aVal = a.querySelector('td:nth-child(3) code')?.textContent || ''; + bVal = b.querySelector('td:nth-child(3) code')?.textContent || ''; break; case 'finding': aVal = a.querySelector('.col-domain')?.textContent.toLowerCase() || ''; @@ -529,28 +564,32 @@ def get_html_template() -> str: }}); }}); // Service-specific filter functions - function createServiceFilter(tableId, searchId, accountId, severityId, statusId, resetId) {{ + function createServiceFilter(tableId, searchId, accountId, regionId, severityId, statusId, resetId) {{ const table = document.getElementById(tableId); if (!table) return; const searchInput = document.getElementById(searchId); const accountFilter = document.getElementById(accountId); + const regionFilter = document.getElementById(regionId); const severityFilter = document.getElementById(severityId); const statusFilter = document.getElementById(statusId); const resetBtn = document.getElementById(resetId); function applyServiceFilters() {{ const searchText = searchInput?.value.toLowerCase() || ''; const accountValue = accountFilter?.value.toLowerCase() || ''; + const regionValue = regionFilter?.value.toLowerCase() || ''; const severityValue = severityFilter?.value.toLowerCase() || ''; const statusValue = statusFilter?.value.toLowerCase() || ''; const rows = table.querySelectorAll('tbody tr'); rows.forEach(row => {{ const rowText = row.textContent.toLowerCase(); const rowAccount = row.dataset.account || ''; + const rowRegion = row.dataset.region || ''; const rowSeverity = row.dataset.severity || ''; const rowStatus = row.dataset.status || ''; let show = true; if (searchText && !rowText.includes(searchText)) show = false; if (accountValue && rowAccount !== accountValue) show = false; + if (regionValue && rowRegion.toLowerCase() !== regionValue) show = false; if (severityValue && rowSeverity !== severityValue) show = false; if (statusValue && rowStatus !== statusValue) show = false; row.style.display = show ? '' : 'none'; @@ -558,20 +597,22 @@ def get_html_template() -> str: }} searchInput?.addEventListener('input', applyServiceFilters); accountFilter?.addEventListener('change', applyServiceFilters); + regionFilter?.addEventListener('change', applyServiceFilters); severityFilter?.addEventListener('change', applyServiceFilters); statusFilter?.addEventListener('change', applyServiceFilters); resetBtn?.addEventListener('click', function() {{ if (searchInput) searchInput.value = ''; if (accountFilter) accountFilter.value = ''; + if (regionFilter) regionFilter.value = ''; if (severityFilter) severityFilter.value = ''; if (statusFilter) statusFilter.value = ''; applyServiceFilters(); }}); }} - createServiceFilter('bedrockTable', 'bedrockSearchInput', 'bedrockAccountFilter', 'bedrockSeverityFilter', 'bedrockStatusFilter', 'bedrockResetFilters'); - createServiceFilter('sagemakerTable', 'sagemakerSearchInput', 'sagemakerAccountFilter', 'sagemakerSeverityFilter', 'sagemakerStatusFilter', 'sagemakerResetFilters'); - createServiceFilter('agentcoreTable', 'agentcoreSearchInput', 'agentcoreAccountFilter', 'agentcoreSeverityFilter', 'agentcoreStatusFilter', 'agentcoreResetFilters'); - createServiceFilter('finservTable', 'finservSearchInput', 'finservAccountFilter', 'finservSeverityFilter', 'finservStatusFilter', 'finservResetFilters'); + createServiceFilter('bedrockTable', 'bedrockSearchInput', 'bedrockAccountFilter', 'bedrockRegionFilter', 'bedrockSeverityFilter', 'bedrockStatusFilter', 'bedrockResetFilters'); + createServiceFilter('sagemakerTable', 'sagemakerSearchInput', 'sagemakerAccountFilter', 'sagemakerRegionFilter', 'sagemakerSeverityFilter', 'sagemakerStatusFilter', 'sagemakerResetFilters'); + createServiceFilter('agentcoreTable', 'agentcoreSearchInput', 'agentcoreAccountFilter', 'agentcoreRegionFilter', 'agentcoreSeverityFilter', 'agentcoreStatusFilter', 'agentcoreResetFilters'); + createServiceFilter('finservTable', 'finservSearchInput', 'finservAccountFilter', 'finservRegionFilter', 'finservSeverityFilter', 'finservStatusFilter', 'finservResetFilters'); // Apply initial filters for main table applyFilters(); @@ -588,6 +629,7 @@ def generate_html_report( account_id: Optional[str] = None, account_ids: Optional[List[str]] = None, timestamp: Optional[str] = None, + regions: list = None, ) -> str: """ Generate HTML report from findings data. @@ -600,6 +642,7 @@ def generate_html_report( account_id: Account ID (for single-account mode) account_ids: List of account IDs (for multi-account mode) timestamp: Optional timestamp string + regions: Optional list of region strings for multi-region filtering Returns: Complete HTML report string @@ -742,14 +785,46 @@ def generate_html_report( service_findings.get("finserv", []), include_data_attrs=True ) + # Build region filter HTML (shared across modes, only shown when multiple regions). + # "Global" tags IAM-only findings and is intentionally excluded from `regions` + # (it must not inflate the region count / tiles), but those findings still appear + # in the tables, so surface a "Global" filter option when any are present. + has_global = any( + (f.get("region") or f.get("Region")) == "Global" for f in all_findings + ) + # Show the filter when there is more than one distinct value to choose from: + # multiple scanned regions, or a single scanned region alongside Global findings. + num_real_regions = len(regions) if regions else 0 + if num_real_regions + (1 if has_global else 0) > 1: + region_options = "".join( + [f'' for r in sorted(regions or [])] + ) + if has_global: + region_options += '' + region_filter = f'
' + bedrock_region_filter = f'
' + sagemaker_region_filter = f'
' + agentcore_region_filter = f'
' + else: + region_filter = "" + bedrock_region_filter = "" + sagemaker_region_filter = "" + agentcore_region_filter = "" + # Mode-specific content num_accounts = len(account_ids) if account_ids else 1 + num_regions = len(regions) if regions else 1 if mode == "multi": title = "Multi-Account AI/ML Security Assessment Report" sidebar_subtitle = "Multi-Account Assessment" account_info = f"Accounts: {num_accounts}" header_account_info = f"{num_accounts} Accounts" - findings_sub = f"Across {num_accounts} accounts" + if num_regions > 1: + findings_sub = f"Across {num_accounts} accounts · {num_regions} regions" + security_checks_sub = f"Evaluated across {num_regions} regions" + else: + findings_sub = f"Across {num_accounts} accounts" + security_checks_sub = "Evaluated per account" account_options = "".join( [ f'' @@ -809,8 +884,16 @@ def generate_html_report( title = "AI/ML Security Assessment Report" sidebar_subtitle = "Assessment Report" account_info = f"Account: {account_id or 'Unknown'}" - header_account_info = f"Account: {account_id or 'Unknown'}" - findings_sub = "Across 1 account" + if num_regions > 1: + header_account_info = ( + f"Account: {account_id or 'Unknown'} · {num_regions} Regions" + ) + findings_sub = f"Across {num_regions} regions" + security_checks_sub = f"Evaluated across {num_regions} regions" + else: + header_account_info = f"Account: {account_id or 'Unknown'}" + findings_sub = "Across 1 account" + security_checks_sub = "Evaluated per account" account_filter = "" bedrock_account_filter = "" sagemaker_account_filter = "" @@ -818,7 +901,51 @@ def generate_html_report( finserv_account_filter = "" account_risk_section = "" - # FinServ (FS-*) — first-class service, rendered only when findings exist + # Build region risk section (shown when multiple regions) + if regions and len(regions) > 1: + region_metrics_html = "" + for reg in sorted(regions): + reg_findings = [ + f for f in all_findings if f.get("region", f.get("Region", "")) == reg + ] + reg_high = sum( + 1 + for f in reg_findings + if f.get("severity", f.get("Severity", "")).lower() == "high" + and f.get("status", f.get("Status", "")).lower() == "failed" + ) + reg_medium = sum( + 1 + for f in reg_findings + if f.get("severity", f.get("Severity", "")).lower() == "medium" + and f.get("status", f.get("Status", "")).lower() == "failed" + ) + reg_low = sum( + 1 + for f in reg_findings + if f.get("severity", f.get("Severity", "")).lower() == "low" + and f.get("status", f.get("Status", "")).lower() == "failed" + ) + reg_total_failed = reg_high + reg_medium + reg_low + + if reg_high > 0: + risk_class = "danger" + border_color = "var(--danger)" + elif reg_medium > 0: + risk_class = "warning" + border_color = "var(--warning)" + else: + risk_class = "" + border_color = "var(--success)" + + region_metrics_html += f"""
{reg}
{reg_total_failed}
{reg_high} High · {reg_medium} Med · {reg_low} Low
""" + + region_risk_section = f"""

Risk by Region

+
{region_metrics_html}
""" + else: + region_risk_section = "" + + # FinServ (FS-*) — first-class industry assessment, rendered only when findings exist # (so non-FinServ accounts and EnableFinServAssessment=false deploys stay clean). finserv_total = ( service_stats.get("finserv", {}).get("passed", 0) @@ -828,55 +955,96 @@ def generate_html_report( finserv_failed = service_stats.get("finserv", {}).get("failed", 0) finserv_passed = service_stats.get("finserv", {}).get("passed", 0) if finserv_total > 0: + finserv_regions = sorted( + { + f.get("region", f.get("Region", "")) + for f in service_findings.get("finserv", []) + if f.get("region", f.get("Region", "")) + } + ) + finserv_region_options = "".join( + [f'' for r in finserv_regions] + ) + finserv_region_filter = ( + '
' + '
' + if finserv_regions + else "" + ) finserv_nav = ( - '' + '' + FINSERV_ICON + " Financial Services" + f'{finserv_total}' ) + industry_nav = ( + '" + ) finserv_filter_option = '' finserv_service_card = ( '
' + FINSERV_ICON_SMALL - + f' FinServ
{finserv_total}
' + + f' Financial Services Risk
{finserv_total}
' + f'
{finserv_failed} Failed \u00b7 {finserv_passed} Passed
' ) + finserv_scope_industry_block = ( + '
' + '
Industry
' + '
' + + FINSERV_ICON_SMALL + + 'Financial Services GenAI Risk
' + ) + finserv_scope_source = ( + f" Financial Services GenAI Risk checks are based on " + f'the AWS User Guide to Governance, Risk, and Compliance for Responsible AI Adoption.' + ) finserv_section = ( '
' '
' + FINSERV_ICON + "Financial Services GenAI Risk Findings
" - '
Scope: this assessment evaluates the deployment Region only — run it per Region for multi-Region GenAI workloads. Severities follow a documented Likelihood × Impact methodology (see docs).
' + '
Scope: this assessment records findings against each resolved CloudFormation TargetRegions entry. These checks are based on ' + f'the AWS User Guide to Governance, Risk, and Compliance for Responsible AI Adoption. ' + "Severities follow a documented Likelihood × Impact methodology (see docs).
" '
' '
' + finserv_account_filter + + finserv_region_filter + '
' '
' '' "
" - '
' + '
Account IDCheck IDFindingDetailsResolutionReferenceSeverityStatus
' + finserv_rows + "
Account IDRegionCheck IDFindingDetailsResolutionReferenceSeverityStatus
" "
" ) else: finserv_nav = "" + industry_nav = "" finserv_filter_option = "" finserv_service_card = "" + finserv_scope_industry_block = "" + finserv_scope_source = "" finserv_section = "" # Fill template html_template = get_html_template() - return html_template.format( + rendered_html = html_template.format( title=title, sidebar_subtitle=sidebar_subtitle, account_info=account_info, header_account_info=header_account_info, account_filter=account_filter, + region_filter=region_filter, timestamp=timestamp, date_display=date_display, security_checks=security_checks, + security_checks_sub=security_checks_sub, total_findings=total_findings, findings_sub=findings_sub, actionable_findings=actionable_findings, @@ -915,9 +1083,37 @@ def generate_html_report( bedrock_account_filter=bedrock_account_filter, sagemaker_account_filter=sagemaker_account_filter, agentcore_account_filter=agentcore_account_filter, - finserv_nav=finserv_nav, + bedrock_region_filter=bedrock_region_filter, + sagemaker_region_filter=sagemaker_region_filter, + agentcore_region_filter=agentcore_region_filter, + industry_nav=industry_nav, finserv_filter_option=finserv_filter_option, finserv_service_card=finserv_service_card, finserv_section=finserv_section, account_risk_section=account_risk_section, + region_risk_section=region_risk_section, + ) + base_scope_source = ( + f"Bedrock, SageMaker, and AgentCore checks are based on the " + f'AWS Well-Architected Framework Generative AI Lens.' ) + rendered_html = rendered_html.replace( + "Based on AWS Well-Architected Framework (Generative AI Lens) and service-specific security documentation.", + base_scope_source, + 1, + ) + if finserv_scope_industry_block: + rendered_html = rendered_html.replace( + 'Amazon Bedrock AgentCore

Amazon Bedrock AgentCore' + + "" + + finserv_scope_industry_block + + "

Finding: + region: str = "", +) -> dict: """ Create a validated finding object @@ -70,9 +73,10 @@ def create_finding( reference: Documentation URL severity: Severity level status: Current status + region: AWS region where the finding was identified Returns: - Finding: Validated finding object + Dict: Validated finding as dictionary Raises: ValidationError: If any field fails validation @@ -84,5 +88,6 @@ def create_finding( Reference=reference, Severity=severity, Status=status, + Region=region, ) return dict(finding.model_dump()) # Convert to regular dictionary diff --git a/aiml-security-assessment/functions/security/generate_consolidated_report/test_generate_report.py b/aiml-security-assessment/functions/security/generate_consolidated_report/test_generate_report.py index b92a6ea..4958bb0 100644 --- a/aiml-security-assessment/functions/security/generate_consolidated_report/test_generate_report.py +++ b/aiml-security-assessment/functions/security/generate_consolidated_report/test_generate_report.py @@ -171,6 +171,8 @@ def test_generate_viewable_report(self): self.assertIn("sidebar", content) self.assertIn("service-icon", content) self.assertIn("theme-toggle", content) + self.assertIn("Assessment Area", content) + self.assertIn("All Assessment Areas", content) # Verify new features from consolidation self.assertIn("Methodology", content) @@ -214,16 +216,29 @@ def test_generate_multi_account_report(self): "status": "Passed", "_service": "agentcore", }, + { + "account_id": "444455556666", + "check_id": "FS-01", + "finding": "FinServ Regional Scope Not Applicable", + "details": "No regional AI/ML resources found.", + "resolution": "No action required.", + "reference": "https://example.com", + "severity": "Informational", + "status": "N/A", + "_service": "finserv", + }, ] service_findings = { "bedrock": [all_findings[0]], "sagemaker": [all_findings[1]], "agentcore": [all_findings[2]], + "finserv": [all_findings[3]], } service_stats = { "bedrock": {"passed": 0, "failed": 1}, "sagemaker": {"passed": 0, "failed": 1}, "agentcore": {"passed": 1, "failed": 0}, + "finserv": {"passed": 0, "failed": 0, "na": 1}, } html_content = generate_report_direct( @@ -250,6 +265,14 @@ def test_generate_multi_account_report(self): self.assertIn("accountFilter", content) self.assertIn("111122223333", content) self.assertIn("444455556666", content) + self.assertIn("

By Industry

", content) + self.assertIn('class="nav-section industry-nav"', content) + self.assertIn("Financial Services Risk", content) + self.assertIn('class="scope-industry"', content) + by_service_nav = content.split("

By Service

", 1)[1].split( + "

By Industry

", 1 + )[0] + self.assertNotIn("Financial Services", by_service_nav) def test_missing_data_fields(self): """Test handling of assessment results with missing fields""" @@ -308,6 +331,7 @@ def test_finserv_renders_when_present(self): "Reference": "https://docs.aws.amazon.com/waf/latest/developerguide/waf-chapter.html", "Severity": "Medium", "Status": "Failed", + "Region": "region-a", }, { "Account_ID": "123456789012", @@ -318,6 +342,7 @@ def test_finserv_renders_when_present(self): "Reference": "https://docs.aws.amazon.com/macie/latest/user/what-is-macie.html", "Severity": "High", "Status": "Passed", + "Region": "region-b", }, ] } @@ -327,12 +352,47 @@ def test_finserv_renders_when_present(self): self.assertIn('', html) + self.assertIn('', html) + self.assertIn('data-scope-service="finserv"', html) + self.assertIn('class="scope-industry"', html) + self.assertIn('class="scope-chip industry-chip"', html) + self.assertIn('class="nav-section industry-nav"', html) + self.assertIn("Financial Services Risk", html) + self.assertIn("Assessment Area", html) + self.assertIn("All Assessment Areas", html) + self.assertIn( + "wellarchitected/latest/generative-ai-lens/generative-ai-lens.html", html + ) + self.assertIn( + "introducing-the-updated-aws-user-guide-to-governance-risk-and-compliance-for-responsible-ai-adoption", + html, + ) + self.assertNotIn("global-FinServ-ComplianceGuide-GenAIRisks-public.pdf", html) + self.assertIn("

By Industry

", html) + by_service_nav = html.split("

By Service

", 1)[1].split( + "

By Industry

", 1 + )[0] + self.assertNotIn("Financial Services", by_service_nav) def test_finserv_omitted_when_absent(self): """REQ-1/REQ-7: with no FinServ data the FinServ section is omitted cleanly.""" html = generate_html_report(self.test_assessment_results) self.assertNotIn('id="finserv"', html) + self.assertNotIn("

By Industry

", html) self.assertNotIn('