Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b365401d | ||
|
|
40ecbd035e | ||
|
|
14bc94a402 | ||
|
|
7391798e8d | ||
|
|
f17916c7c2 | ||
|
|
c6d95e22fa | ||
|
|
6a4df430c6 | ||
|
|
725b0060f1 | ||
|
|
f3d2e51aab | ||
|
|
544ff1cdc1 | ||
|
|
1bb6b3cb39 |
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.39.2
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin
|
||||
|
||||
### For Check Logic Bug
|
||||
|
||||
```markdown
|
||||
```
|
||||
### AI Assessment [Experimental]: Check Logic Bug
|
||||
|
||||
**Component**: {component from issue template}
|
||||
@@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Bug (non-check)
|
||||
|
||||
```markdown
|
||||
```
|
||||
### AI Assessment [Experimental]: Bug
|
||||
|
||||
**Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other}
|
||||
@@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Already Fixed
|
||||
|
||||
```markdown
|
||||
```
|
||||
### AI Assessment [Experimental]: Already Fixed
|
||||
|
||||
**Component**: {component}
|
||||
@@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Feature Request
|
||||
|
||||
```markdown
|
||||
```
|
||||
### AI Assessment [Experimental]: Feature Request
|
||||
|
||||
**Component**: {component}
|
||||
@@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Not a Bug
|
||||
|
||||
```markdown
|
||||
```
|
||||
### AI Assessment [Experimental]: Not a Bug
|
||||
|
||||
**Component**: {component}
|
||||
@@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Needs More Information
|
||||
|
||||
```markdown
|
||||
```
|
||||
### AI Assessment [Experimental]: Needs More Information
|
||||
|
||||
**Component**: {component or "Unknown"}
|
||||
|
||||
@@ -52,16 +52,6 @@ provider/alibabacloud:
|
||||
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
|
||||
|
||||
provider/huaweicloud:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/huaweicloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/huaweicloud/**"
|
||||
|
||||
provider/image:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/image/**"
|
||||
- any-glob-to-any-file: "tests/providers/image/**"
|
||||
|
||||
provider/cloudflare:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
|
||||
@@ -92,11 +82,6 @@ provider/linode:
|
||||
- any-glob-to-any-file: "prowler/providers/linode/**"
|
||||
- any-glob-to-any-file: "tests/providers/linode/**"
|
||||
|
||||
provider/stackit:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/stackit/**"
|
||||
- any-glob-to-any-file: "tests/providers/stackit/**"
|
||||
|
||||
github_actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ".github/workflows/*"
|
||||
|
||||
@@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the
|
||||
|
||||
### Available Templates
|
||||
|
||||
#### Container Releases
|
||||
**Container Releases**
|
||||
- `container-release-started.json`: Simple one-line notification when container push starts
|
||||
- `container-release-completed.json`: Simple one-line notification when container release completes
|
||||
|
||||
#### Deployments
|
||||
**Deployments**
|
||||
- `deployment-started.json`: Deployment start notification with Block Kit formatting
|
||||
- `deployment-completed.json`: Deployment completion notification (updates the start message)
|
||||
|
||||
@@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status:
|
||||
### Container Release (Simple One-Line)
|
||||
|
||||
**Start message:**
|
||||
```text
|
||||
```
|
||||
API container release 4.5.0 push started... View run
|
||||
```
|
||||
|
||||
**Completion message (success):**
|
||||
```text
|
||||
```
|
||||
[✓] API container release 4.5.0 push completed successfully! View run
|
||||
```
|
||||
|
||||
**Completion message (failure):**
|
||||
```text
|
||||
```
|
||||
[✗] API container release 4.5.0 push failed View run
|
||||
```
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ jobs:
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
objects.githubusercontent.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
|
||||
@@ -55,10 +55,6 @@ jobs:
|
||||
# Pin must match .pre-commit-config.yaml so prek and CI behave identically.
|
||||
# pnpm dlx doesn't accept --ignore-scripts as a flag; the env var
|
||||
# disables postinstall scripts on transitives the same way.
|
||||
#
|
||||
# Files come from `git ls-files` because markdownlint doesn't traverse
|
||||
# dot-directories, so `.github/**/*.md` went unlinted.
|
||||
# `.markdownlintignore` still applies to the listed paths.
|
||||
env:
|
||||
pnpm_config_ignore_scripts: 'true'
|
||||
run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 --
|
||||
run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md'
|
||||
|
||||
@@ -35,7 +35,6 @@ jobs:
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
objects.githubusercontent.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ jobs:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.39",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.41.0"
|
||||
version = "1.40.2"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
@@ -491,6 +491,12 @@ override-dependencies = [
|
||||
# deployed from this lock with `uv sync --locked`, so the override applies to what runs.
|
||||
# Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x.
|
||||
"cryptography==50.0.0",
|
||||
# prowler@master hard-pins alibabacloud-tea-openapi and oci in [project.dependencies];
|
||||
# the SDK bumped both to lift their cryptography caps. A constraint cannot satisfy the
|
||||
# new pins against the older master rev locked here, so override until the SDK bump
|
||||
# propagates to the pinned master rev, then drop these two.
|
||||
"alibabacloud-tea-openapi==0.4.6",
|
||||
"oci==2.184.1",
|
||||
"azure-mgmt-containerservice==34.1.0",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.41.0
|
||||
version: 1.40.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ constraints = [
|
||||
{ name = "zstd", specifier = "==1.5.7.2" },
|
||||
]
|
||||
overrides = [
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
|
||||
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
|
||||
{ name = "cryptography", specifier = "==50.0.0" },
|
||||
{ name = "dulwich", specifier = "==1.2.5" },
|
||||
@@ -386,6 +387,7 @@ overrides = [
|
||||
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
|
||||
{ name = "oci", specifier = "==2.184.1" },
|
||||
{ name = "okta", specifier = "==3.4.2" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
|
||||
]
|
||||
@@ -4835,8 +4837,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
|
||||
version = "5.39.1"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.39#7391798e8dfb1ea0f496846d6b4796a547b316f5" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4935,7 +4937,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.41.0"
|
||||
version = "1.40.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -5035,7 +5037,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.39" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
|
||||
@@ -4,117 +4,6 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.39.0" description="August 13, 2026">
|
||||
### 🤖 Lighthouse AI — Finding Skills
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI now embeds a Skills menu on every finding, answering the questions an analyst actually asks. **Contextual Fix** produces the fix for the finding, **Triage Decision** judges whether it is real and closes it out when it is not, and **Systemic Scope** determines whether the problem is a one-off or everywhere. A free-form "Ask Lighthouse anything" prompt sits in the same menu, and each run shows its progress and offers follow-up actions such as creating a Jira issue or muting the finding.
|
||||
|
||||

|
||||
|
||||
Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse).
|
||||
|
||||
### ☁️ Azure Management Group Onboarding
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Azure subscriptions no longer onboard one at a time. Choose "Add Multiple Subscriptions With Azure Management Group" in the add-provider wizard, enter the Microsoft Entra tenant ID, and authenticate once with a single tenant-wide service principal: Prowler discovers the entire management-group hierarchy under the tenant root, lets you select the subscriptions to onboard, and creates their providers with the management-group structure preserved. Azure now matches the one-step onboarding that AWS Organizations and GCP organizations already have.
|
||||
|
||||

|
||||
|
||||
Read more in the [Azure Management Groups documentation](https://docs.prowler.com/user-guide/tutorials/prowler-cloud-azure-management-groups).
|
||||
|
||||
### ✅ Findings Triage — Verify MANUAL Findings as PASS
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Checks that require human judgment report `MANUAL` findings. For these findings, and only for them, the triage status selector now offers **Resolved**: choosing it asks for the required written evidence and verifies the finding as passing. The finding then reports an effective `PASS` while preserving the raw `MANUAL` scan result, across findings, finding groups, compliance reports, and scans, with the attestation's author, evidence, and validity always visible. Attestations expire automatically after 90 days, or as soon as a new scan reports a real failure, returning the finding to the review queue.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Read more in the [Findings Triage documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage#verify-a-manual-finding-as-pass).
|
||||
|
||||
### ☁️ Prowler Cloud MCP — Organizations Management and Grouped Jira Dispatch
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
The hosted Prowler Cloud MCP server adds eight organization tools, so an agent can onboard and manage entire cloud organizations end to end: create the organization, discover its accounts, subscriptions, and projects, apply the selection, and manage the resulting providers. The tools cover AWS Organizations, GCP organizations, and Azure tenant root management groups, and they are available to Lighthouse AI.
|
||||
|
||||
`prowler_send_findings_to_jira` also gains Cloud-only dispatch capabilities: select failed findings by check IDs against the latest completed scan, and send them in grouped mode, one Jira work item per check listing up to 50 affected resources, with per-group error reporting.
|
||||
|
||||
Read more in the [Prowler MCP tools documentation](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools) and its [Jira operations reference](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools#jira-operations).
|
||||
|
||||
### 🕸️ Attack Paths — Grouped Graph with Outcome Destinations
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
The Attack Paths graph now reads from source to destination. Resources of the same class collapse into a single expandable node with a count, clicking reveals its members, and every path terminates in an explicit outcome node naming the destination impact: code execution, privilege escalation, public exposure, or resource inventory. The per-account hub node is gone, and the clicked resource stays highlighted while its findings are expanded.
|
||||
|
||||

|
||||
|
||||
Explore the full Attack Paths query catalog at [Prowler Hub](https://hub.prowler.com/attack-paths).
|
||||
|
||||
Read more in the [Attack Paths documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths).
|
||||
|
||||
### 📚 New Compliance Framework — CMMC 2.0
|
||||
|
||||
The Cybersecurity Maturity Model Certification (CMMC) is the certification the US Department of Defense requires from contractors and suppliers that handle federal contract data. Prowler now includes CMMC 2.0 as a universal framework with all 149 requirements defined by the CMMC Program rule (32 CFR Part 170), organized in its three levels:
|
||||
|
||||
- **Level 1 (Foundational):** 15 requirements for the basic safeguarding of Federal Contract Information, from FAR 52.204-21.
|
||||
- **Level 2 (Advanced):** 110 requirements from NIST SP 800-171 Rev 2, protecting Controlled Unclassified Information.
|
||||
- **Level 3 (Expert):** 24 enhanced requirements from NIST SP 800-172 for the most sensitive programs.
|
||||
|
||||
Requirements map to Prowler checks across AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud, and Microsoft 365, so one framework reports the compliance posture of the whole estate.
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
Twenty new Entra ID checks expand the coverage of CIS Microsoft 365 Foundations Benchmark v7.0.0:
|
||||
|
||||
- **Password protection:** custom banned password list, on-premises enforcement, and lockout threshold and duration.
|
||||
- **Default user permissions:** security group and Microsoft 365 group creation restricted, and guest invitations limited to allowed domains.
|
||||
- **Conditional Access:** high and medium sign-in risk blocked, authentication transfer blocked, untrusted locations blocked, trusted named locations defined, sign-in frequency enforced, and token protection enforced.
|
||||
- **Sessions and authentication methods:** idle session timeout configured, email one-time passcodes disabled, and Microsoft Authenticator context shown.
|
||||
- **PIM and access reviews:** approval required to activate the Global Administrator and Privileged Role Administrator roles, and access reviews configured for guest users and privileged roles.
|
||||
|
||||
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
|
||||
|
||||
#### AWS
|
||||
|
||||
Two new checks detect hardcoded secrets:
|
||||
|
||||
- `batch_job_definition_no_secrets` scans Batch job definition environment variables and command parameters. Thanks to @praneetrajv!
|
||||
- `awslambda_layer_no_secrets_in_content` scans Lambda layer package content. Thanks to @ganiganesh25!
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @praneetrajv: AWS `batch_job_definition_no_secrets` check ([#12117](https://github.com/prowler-cloud/prowler/pull/12117))
|
||||
- @ganiganesh25: AWS `awslambda_layer_no_secrets_in_content` check ([#12233](https://github.com/prowler-cloud/prowler/pull/12233))
|
||||
- @andoniaf: GitHub `organization_repository_creation_limited` now reports low severity when repository creation is limited to private or internal visibility ([#12164](https://github.com/prowler-cloud/prowler/pull/12164))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.39.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.38.0" description="August 6, 2026">
|
||||
### 📌 Compliance Watchlist
|
||||
|
||||
|
||||
@@ -224,9 +224,9 @@ Each check **must** populate the report with a unique identifier for the audited
|
||||
- `resource_name`: Description of the configuration (e.g., "SharePoint Settings")
|
||||
- GitHub
|
||||
- Resource ID — `report.resource_id`.
|
||||
- The ID of the GitHub resource. This is a system-generated integer that uniquely identifies the resource within the GitHub platform.
|
||||
- The ID of the Github resource. This is a system-generated integer that uniquely identifies the resource within the Github platform.
|
||||
- Resource Name — `report.resource_name`.
|
||||
- The name of the GitHub resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
|
||||
- The name of the Github resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
|
||||
|
||||
### Configurable Checks in Prowler
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ Once you have decided the provider you want or need to add to Prowler, the next
|
||||
- **SDK Providers**: Low complexity. You have mature examples like AWS, Azure, GCP, Kubernetes, etc. that you can leverage to implement your provider.
|
||||
- **API Providers**: Medium complexity. You need to implement the authentication and session management, and the API calls to the provider. You now have NHN and MongoDB Atlas as example to follow.
|
||||
- **Tool/Wrapper Providers**: High complexity. You need to implement the argument/output mapping to the provider and handle problems that the tool/wrapper may have. You now have IAC and the PowerShell wrapper as example to follow.
|
||||
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and GitHub (PyGithub SDK + graphql API requests) as examples.
|
||||
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and Github (PyGithub SDK + graphql API requests) as examples.
|
||||
|
||||
### Determining Regional vs Non-Regional Architecture
|
||||
|
||||
@@ -814,7 +814,7 @@ class YourProviderMutelist(Mutelist):
|
||||
Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality.
|
||||
|
||||
<Note>
|
||||
Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does.
|
||||
Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does.
|
||||
</Note>
|
||||
|
||||
**File:** `prowler/providers/<provider_name>/lib/regions/<provider_name>_regions.py`
|
||||
@@ -1773,7 +1773,7 @@ The implementation of the mutelist is the same as the [SDK providers](#step-5-im
|
||||
Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality.
|
||||
|
||||
<Note>
|
||||
Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does.
|
||||
Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does.
|
||||
</Note>
|
||||
|
||||
**File:** `prowler/providers/<provider_name>/lib/regions/<provider_name>_regions.py`
|
||||
|
||||
@@ -182,8 +182,7 @@
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-app-s3-integration",
|
||||
"user-guide/tutorials/prowler-app-security-hub-integration",
|
||||
"user-guide/tutorials/prowler-app-jira-integration",
|
||||
"user-guide/tutorials/prowler-app-slack-integration"
|
||||
"user-guide/tutorials/prowler-app-jira-integration"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -128,12 +128,12 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.39.0"
|
||||
PROWLER_API_VERSION="5.39.0"
|
||||
PROWLER_UI_VERSION="5.38.0"
|
||||
PROWLER_API_VERSION="5.38.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
You can find the latest versions of Prowler Local Server in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
|
||||
You can find the latest versions of Prowler Local Server in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
|
||||
</Note>
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 563 KiB |
|
Before Width: | Height: | Size: 401 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 366 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 93 KiB |
@@ -92,7 +92,6 @@ li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::
|
||||
li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-slack-integration"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after,
|
||||
|
||||
@@ -495,7 +495,7 @@ aws:
|
||||
|
||||
# AWS CloudTrail Configuration
|
||||
# aws.cloudtrail_threat_detection_privilege_escalation
|
||||
threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is a privilege_escalation attack event, by default is 0.2 (20%)
|
||||
threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is an privilege_escalation attack event, by default is 0.2 (20%)
|
||||
threat_detection_privilege_escalation_minutes: 1440 # Past minutes to search from now for privilege_escalation attacks, by default is 1440 minutes (24 hours)
|
||||
threat_detection_privilege_escalation_actions:
|
||||
[
|
||||
|
||||
@@ -13,7 +13,7 @@ StackIT support in Prowler is community-maintained. For commercial support or to
|
||||
Before running Prowler with the StackIT provider, ensure you have:
|
||||
|
||||
1. A StackIT account with at least one project
|
||||
2. A StackIT service account key file with project permissions for the services to scan. `iaas.viewer` is sufficient for the currently shipped IaaS checks. For SKE, assign the project-scoped `Reader` role as a broad read-only convenience, or create a custom role with `resource-manager.project.get`, `ske.cluster.list`, `ske.cluster.get`, and `ske.version.list`. The `resource-manager.project.get` permission is optional for scanning; it only lets Prowler verify and display the project name. See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup.
|
||||
2. A StackIT service account key file with permissions on the project (`iaas.viewer` is enough for the currently shipped IaaS checks; `project.owner` works for any future service). See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup.
|
||||
3. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli))
|
||||
|
||||
## Prowler CLI
|
||||
@@ -124,9 +124,7 @@ Prowler outputs findings to the console and writes reports to the `output/` dire
|
||||
|
||||
| Service | StackIT API | Description | Example Checks |
|
||||
|---------|-------------|-------------|----------------|
|
||||
| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted`, `iaas_server_public_ip_attached` |
|
||||
| **Object Storage** | `objectstorage` | Buckets, object lock, retention policies, access keys | `objectstorage_bucket_object_lock_enabled`, `objectstorage_bucket_retention_policy`, `objectstorage_access_key_expiration` |
|
||||
| **Kubernetes Engine** | `ske` | Managed Kubernetes clusters | `ske_cluster_no_public_endpoint` |
|
||||
| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted` |
|
||||
|
||||
Additional services will be added in future releases. Track progress in the [Prowler release notes](https://github.com/prowler-cloud/prowler/releases).
|
||||
|
||||
@@ -138,8 +136,6 @@ If the scan fails with a 401 error, the service account key is no longer valid (
|
||||
|
||||
### Permission Errors
|
||||
|
||||
If the connection probe warns that Resource Manager access could not be verified with a 403 response, Prowler continues to service discovery. Grant the optional `resource-manager.project.get` permission to verify and display the project name.
|
||||
|
||||
If a service call fails with a 403 response, the service account is missing a permission required by that service. `iaas.viewer` is sufficient for the shipped IaaS checks. The `ske_cluster_no_public_endpoint` check lists clusters and therefore requires `ske.cluster.list`; STACKIT least-privilege custom-role examples also include `ske.cluster.get` and `ske.version.list`. Service API calls remain authoritative and stop the scan when access is denied.
|
||||
If checks fail with a 403 error, the service account is missing the required role on the project. Re-check the role assignment in the StackIT portal (`iaas.viewer` is the minimum for the shipped IaaS checks).
|
||||
|
||||
For detailed setup steps, see the [Authentication guide](/user-guide/providers/stackit/authentication).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 'Alerts'
|
||||
sidebarTitle: 'Alerts'
|
||||
description: 'Create alerts from Prowler Cloud findings, deliver them to email recipients and Slack channels, and monitor relevant security changes after scans or in daily digests.'
|
||||
description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
@@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.26.0" />
|
||||
|
||||
Alerts notify their destinations — email recipients, Slack channels, or both — when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
|
||||
Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
|
||||
|
||||
<SubscriptionBanner />
|
||||
|
||||
@@ -19,13 +19,12 @@ Before creating Alerts, ensure that:
|
||||
|
||||
* At least one scan has completed and produced findings.
|
||||
* The user role includes the `manage_alerts` permission.
|
||||
* To deliver Alerts to Slack channels, a Slack workspace is connected, at least one channel is authorized on it, and the integration's connection check has confirmed that channel. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration).
|
||||
|
||||
The `manage_alerts` permission is required to create, edit, test, enable, disable, and delete Alerts. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details.
|
||||
|
||||
## How Alerts Work
|
||||
|
||||
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and notifies the Alert's destinations when matching findings exist: an email digest to each recipient, a message to each Slack channel, or both. Destination kinds are independent — neither requires the other, and neither displaces the other.
|
||||
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and sends an email digest when matching findings exist.
|
||||
|
||||
<Note>
|
||||
Alerts evaluate findings with status `FAIL` only. Findings with status `PASS` or `MANUAL`, and muted findings, never trigger an Alert regardless of the saved filters.
|
||||
@@ -54,7 +53,6 @@ To create an Alert:
|
||||
* **Description:** Add optional context for the Alert.
|
||||
* **Frequency:** Select when Prowler Cloud should evaluate the Alert.
|
||||
* **Recipients:** Select the recipients who should receive the email digest.
|
||||
* **Destination channels:** Select the Slack channels that should receive the Alert. See [Slack Channel Destinations](#slack-channel-destinations).
|
||||
|
||||

|
||||
|
||||
@@ -88,18 +86,11 @@ Navigate to **Alerts** to review and manage existing Alerts.
|
||||
|
||||

|
||||
|
||||
The **Destinations** column summarizes where each Alert delivers, without the Alert being opened:
|
||||
|
||||
* **Email recipients:** The first address, plus a count of the rest, such as `security@example.com +2 more`.
|
||||
* **Slack channels:** The first channel, plus a count of the rest, such as `#sec-alerts +1 more`.
|
||||
|
||||
Each summary is omitted when that destination kind is empty, and the column reads **No destinations** when an Alert has neither.
|
||||
|
||||
Each Alert provides these actions:
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| Edit | Update name, description, recipients, Slack channels, frequency, or filters. |
|
||||
| Edit | Update name, description, recipients, frequency, or filters. |
|
||||
| Enable/Disable | Start or stop Alert evaluation without deleting the Alert. |
|
||||
| Delete | Permanently remove the Alert. |
|
||||
|
||||
@@ -134,37 +125,6 @@ By default, the **organization owner** receives a **daily digest** for **critica
|
||||
|
||||
If a recipient unsubscribes from Alerts, that address stops receiving digests until it is reconfirmed.
|
||||
|
||||
An Alert does not require email recipients: an Alert that targets Slack channels only is accepted with those channels as its sole destinations. An Alert with no destinations at all stays valid and keeps evaluating its filters, but it delivers nothing.
|
||||
|
||||
## Slack Channel Destinations
|
||||
|
||||
<VersionBadge version="5.40.0" />
|
||||
|
||||
An Alert can post to Slack channels alongside its email recipients, or instead of them. The **Destination channels** field sits directly below **Recipients** in the Alert form, both when creating an Alert and when editing one. When the Alert matches findings, Prowler Cloud posts a message to each of its channels and sends the email digest to each of its recipients, independently of each other.
|
||||
|
||||
The channels offered are the confirmed channels of the connected Slack integration, never the whole Slack workspace. Widening the pool takes two steps on the integration: authorize the channel there, then run its connection check, which confirms the channel by posting a one-time confirmation message to it. Once confirmed, the channel is selectable on every Alert. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration) for connecting a workspace, authorizing its channels, and confirming them.
|
||||
|
||||
A channel that was authorized a moment ago but does not appear in the Alert form has not been confirmed yet. Run **Test connection** on the Slack integration, then reopen the Alert form.
|
||||
|
||||
Private channels are identified as **Private** both in the open channel list and on the selected channels once the list is closed, so a private destination is never mistaken for a public one.
|
||||
|
||||
### When Slack Channels Cannot Be Selected
|
||||
|
||||
The field is always present, so channel delivery is never silently missing. It reports why it cannot be used:
|
||||
|
||||
| State | What the Alert form shows |
|
||||
|-------|---------------------------|
|
||||
| No Slack workspace connected | The field is visible but cannot be edited, explaining that posting Alerts to Slack channels needs a connected Slack workspace, with a link to the Slack integration. |
|
||||
| Workspace connected, no confirmed channels | A notice that no channels are available yet and that they are authorized and confirmed on the Slack integration, with the same link. |
|
||||
|
||||
In both states the rest of the Alert is unaffected: it can still be created or saved with its filters, frequency, and email recipients.
|
||||
|
||||
<Note>
|
||||
Slack destinations stay in step with the integration. Removing a channel from the integration's authorized set — or disconnecting the Slack integration altogether — removes that channel from every Alert that targeted it, so an Alert never keeps a destination Prowler can no longer deliver to. The Alert keeps its filters, frequency, and email recipients, and future delivery to that channel stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Restoring delivery means authorizing and confirming the channel again on the integration, then selecting it again on the Alert.
|
||||
</Note>
|
||||
|
||||
Saving an Alert that names a channel which is not a confirmed channel of a connected Slack integration is refused, and the reason is reported on the Alert form. Authorize and confirm the channel on the Slack integration, or remove it from the Alert, and save again.
|
||||
|
||||
## Email Notifications
|
||||
|
||||
When an Alert matches findings, Prowler Cloud sends a security alert email that summarizes the matching findings. The email includes:
|
||||
@@ -181,6 +141,6 @@ When an Alert matches findings, Prowler Cloud sends a security alert email that
|
||||
|
||||
* **Start with focused filters:** Create Alerts for specific high-priority scopes, such as critical findings, production providers, or important services.
|
||||
* **Use clear names:** Choose names that explain the intent of the Alert.
|
||||
* **Review destinations regularly:** Keep recipient lists and channel selections aligned with current ownership.
|
||||
* **Review recipients regularly:** Keep recipient lists aligned with current ownership.
|
||||
* **Test before saving edits:** Use **Test** after changing filters to confirm that the Alert matches the expected findings.
|
||||
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or destinations.
|
||||
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or recipients.
|
||||
|
||||
@@ -16,24 +16,26 @@ Attack Paths analyzes relationships between cloud resources, permissions, and se
|
||||
By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources.
|
||||
|
||||
<Note>
|
||||
Attack Paths is currently available for **AWS** providers. Support for additional providers is planned.
|
||||
Attack Paths is currently available for **AWS** providers. Support for
|
||||
additional providers is planned.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The following prerequisites are required for Attack Paths:
|
||||
|
||||
- **An AWS provider is configured** with valid credentials. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
|
||||
- **An AWS provider is configured** with valid credentials in Prowler Cloud. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
|
||||
- **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required.
|
||||
|
||||
## How Attack Paths Scans Work
|
||||
|
||||
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. When a scan produces graph data, it maps relationships between IAM principals, policies, trust configurations, and other resources. A scan can complete without producing graph data.
|
||||
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources.
|
||||
|
||||
When graph data is ready, the scan appears in the Attack Paths scan table with a check in the **Graph** column and can be selected regardless of its current status. Scans without graph data remain visible but cannot be selected. If a new scan cycle starts after graph data is available, the previous cycle remains available while the new scan runs.
|
||||
Once the scan finishes and graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status and a check in the **Graph** column. Scans that are still queued or running remain visible, but they cannot be selected until graph data is ready.
|
||||
|
||||
<Note>
|
||||
Prowler Cloud and Prowler Private Cloud scan configured providers every **24 hours** by default, so Attack Paths data stays up to date automatically.
|
||||
Since Prowler scans all configured providers every **24 hours** by default,
|
||||
Attack Paths data stays up to date automatically.
|
||||
</Note>
|
||||
|
||||
## Accessing Attack Paths
|
||||
@@ -64,7 +66,7 @@ The scans table displays all Attack Paths scans with the following columns:
|
||||
- **Graph:** Whether Attack Paths graph data is available for the scan.
|
||||
- **Duration:** Total scan time.
|
||||
|
||||
To select a scan for analysis, click any row with a check in the **Graph** column. A row can remain selectable while a new scan cycle runs because Attack Paths keeps the graph from the previous completed cycle available.
|
||||
To select a scan for analysis, click the radio button on any row with a **Completed** status and available graph data.
|
||||
|
||||
<img
|
||||
src="/images/prowler-app/attack-paths/scan-list-table.png"
|
||||
@@ -73,7 +75,8 @@ To select a scan for analysis, click any row with a check in the **Graph** colum
|
||||
/>
|
||||
|
||||
<Note>
|
||||
Only scans with graph data can be selected. Disabled rows include a tooltip that explains why the graph is not available yet.
|
||||
Only scans with graph data can be selected. Disabled rows include a tooltip
|
||||
that explains why the graph is not available yet.
|
||||
</Note>
|
||||
|
||||
## Choosing a Query
|
||||
@@ -94,7 +97,9 @@ To choose a query, click the dropdown and select from the available options. Eac
|
||||
Once selected, a description panel appears below the dropdown with more context about the query.
|
||||
|
||||
<Note>
|
||||
In Prowler Cloud and Prowler Private Cloud, the query selector hides built-in queries confirmed empty for the selected scan. Built-in queries without a confirmed empty result and the **Custom openCypher query** remain visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries).
|
||||
In Prowler Cloud and Prowler Private Cloud, the query selector hides queries
|
||||
confirmed empty for the selected scan, so only queries that return data remain
|
||||
visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries).
|
||||
</Note>
|
||||
|
||||
## Configuring Query Parameters
|
||||
@@ -115,7 +120,7 @@ For example, **Internet-Exposed EC2 with Sensitive S3 Access** uses **Tag key**
|
||||
|
||||
## Writing Custom openCypher Queries
|
||||
|
||||
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalog.
|
||||
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalogue.
|
||||
|
||||
To write a custom query, select **Custom openCypher query** from the query dropdown. A code editor with syntax highlighting and line numbers appears, ready to receive the query.
|
||||
|
||||
@@ -187,7 +192,11 @@ Custom queries traverse the same Cartography graph the built-in queries use. Nod
|
||||
For the complete reference, including the graph model, list-typed and JSON-encoded properties, performance guidance, and openCypher compatibility rules, see [Attack Paths Queries](/developer-guide/attack-paths-queries) in the Developer Guide.
|
||||
|
||||
<Note>
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan via the `prowler_get_attack_paths_cartography_schema` tool. This guarantees that generated queries match the schema version pinned by the running Prowler release.
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact
|
||||
Cartography schema for the active scan via the
|
||||
`prowler_get_attack_paths_cartography_schema` tool. This guarantees that
|
||||
generated queries match the schema version pinned by the running Prowler
|
||||
release.
|
||||
</Note>
|
||||
|
||||
## Executing a Query
|
||||
@@ -212,22 +221,12 @@ If the query returns no results, an informational message appears. Common reason
|
||||
|
||||
After a successful execution, the graph visualization renders below the query builder. The graph maps relationships between cloud resources, IAM entities, public exposure, and security findings.
|
||||
|
||||
### Grouped Graphs and Query Outcomes
|
||||
|
||||
<VersionBadge version="5.39.0" />
|
||||
|
||||
Prowler Cloud and Prowler Private Cloud group resources of the same class and graph level into expandable nodes. Built-in query graphs also end with a query outcome, which states the result that the path can lead to.
|
||||
|
||||
Prowler Local Server keeps the flat graph view, including the provider root. Custom openCypher queries do not have a catalog outcome, so their graphs do not include an outcome node.
|
||||
|
||||
### Node Types
|
||||
|
||||
- **Grouped resource nodes:** Represent multiple resources of the same class in Prowler Cloud and Prowler Private Cloud. A number in the upper-right corner shows how many resources the node contains. A red outline indicates that one or more resources in the group have findings.
|
||||
- **Resource nodes:** Represent individual cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets. A class with one resource remains an individual node.
|
||||
- **Provider root nodes:** Represent the AWS account or provider root for the selected scan.
|
||||
- **Resource nodes:** Represent cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets.
|
||||
- **Internet nodes:** Represent exposure from the public internet.
|
||||
- **Finding nodes:** Represent Prowler findings linked to resources. Finding colors indicate risk level, such as critical, high, medium, or low.
|
||||
- **Outcome nodes:** Mark the terminal result of a built-in query in Prowler Cloud and Prowler Private Cloud. The orange node displays outcomes such as **Code execution**, **Privilege escalation**, **Public exposure**, or **Resource inventory**. A dashed ring and the label **Latent outcome** indicate an inventory or another partial outcome.
|
||||
- **Provider root nodes:** Represent the AWS account or provider root in the Prowler Local Server flat graph.
|
||||
|
||||
### Edge Types
|
||||
|
||||
@@ -235,7 +234,7 @@ Prowler Local Server keeps the flat graph view, including the provider root. Cus
|
||||
- **Finding edges:** Dashed relationships between resources and their associated findings.
|
||||
- **Highlighted paths:** Green edges that show the active path when you hover a node or focus a finding.
|
||||
|
||||
The standard graph view includes a minimap and a legend below the canvas. The legend shows the visible node types, finding risk levels, node states, and edge types present in the current view. Prowler Local Server also displays the provider root in the legend.
|
||||
The standard graph view includes a minimap and a legend below the canvas. The legend shows the provider roots, visible node types, finding risk levels, node states, and edge types present in the current view.
|
||||
|
||||
<img
|
||||
src="/images/prowler-app/attack-paths/graph-visualization.png"
|
||||
@@ -245,32 +244,17 @@ The standard graph view includes a minimap and a legend below the canvas. The le
|
||||
|
||||
## Interacting with the Graph
|
||||
|
||||
The graph supports these interactions:
|
||||
The graph banner describes the main interactions:
|
||||
|
||||
- Click a node with a number in the upper-right corner to expand its resource group.
|
||||
- Click a finding to focus its connected path.
|
||||
- Click a resource with findings to show or hide its related findings.
|
||||
- Hover a node to highlight its connected path.
|
||||
|
||||
### Expanding Resource Groups
|
||||
|
||||
In Prowler Cloud and Prowler Private Cloud, a node with a number in its upper-right corner represents a resource group. The number is the total number of resources in the group.
|
||||
|
||||
- Click the group node to display its resources. Multiple groups can remain expanded, and the graph automatically fits the visible nodes to the canvas.
|
||||
- Double-click any resource revealed from a group to collapse that group.
|
||||
- Click **Collapse all groups** in the graph toolbar to close every expanded group. This control appears only while at least one group is expanded.
|
||||
|
||||
<img
|
||||
src="/images/prowler-app/attack-paths/graph-visualization-expanded.png"
|
||||
alt="Attack Paths graph showing an expanded AWS Role group and remaining numbered resource groups"
|
||||
width="700"
|
||||
/>
|
||||
|
||||
### Showing Related Findings
|
||||
|
||||
Resource nodes with related findings are clickable. Click one of these resources to show its finding nodes. Click the resource again to hide them.
|
||||
|
||||
The selected resource is highlighted in green while its findings are visible. The graph automatically fits the selected resource and its related findings when the findings are shown.
|
||||
The graph automatically fits the selected resource and its related findings when the findings are shown.
|
||||
|
||||
### Focusing a Finding Path
|
||||
|
||||
@@ -295,12 +279,12 @@ The toolbar in the top-right corner of the graph provides:
|
||||
|
||||
- **Zoom in / Zoom out:** Adjust the zoom level
|
||||
- **Fit graph to view:** Reset the view to fit the visible graph
|
||||
- **Collapse all groups:** Close every expanded resource group. This control appears only when a group is expanded
|
||||
- **Export graph:** Download the current graph, including its grouped or expanded state and outcome, as a PNG file
|
||||
- **Export graph:** Download the current graph as a PNG file
|
||||
- **Fullscreen:** Open the graph in a full-size modal
|
||||
|
||||
<Note>
|
||||
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within the graph area.
|
||||
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within
|
||||
the graph area.
|
||||
</Note>
|
||||
|
||||
## Viewing Finding Details
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
---
|
||||
title: "Slack Integration"
|
||||
sidebarTitle: 'Slack'
|
||||
description: 'Connect a Slack workspace to Prowler Cloud, authorize the channels Prowler posts to, and verify the connection.'
|
||||
---
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.40.0" />
|
||||
|
||||
<SubscriptionBanner />
|
||||
|
||||
Prowler Cloud connects to a Slack workspace so security updates arrive where teams already work. Connecting takes one approval in Slack — there is no bot token to create, copy, or store by hand — and Prowler records the set of channels it is authorized to post to.
|
||||
|
||||
Integrating Prowler Cloud with Slack provides:
|
||||
|
||||
* **Approval-based setup:** Approve Prowler once in Slack instead of building a Slack app and pasting a token.
|
||||
* **Confirmed destinations:** The connection check verifies every authorized channel and confirms each new one in the channel itself, so a channel Prowler cannot reach is reported before anything depends on it.
|
||||
* **Controlled reach:** Prowler posts only to the channels authorized on the integration, and private channels stay invisible until the Prowler app is invited to them.
|
||||
|
||||
<Note>
|
||||
This guide covers the Slack integration in Prowler Cloud. It is unrelated to the Prowler CLI `--slack` flag, which posts a scan summary from the command line using a self-created Slack app and the `SLACK_API_TOKEN` and `SLACK_CHANNEL_NAME` environment variables — see [CLI Integrations](/user-guide/cli/tutorials/integrations) for that feature.
|
||||
</Note>
|
||||
|
||||
## How the Slack Integration Works
|
||||
|
||||
When connected and configured:
|
||||
|
||||
1. A Slack workspace is approved once through Slack's app install flow, and Prowler stores the resulting credential encrypted.
|
||||
2. Prowler reads the channels it can post to: the workspace's public channels, plus the private channels the Prowler app has been invited to.
|
||||
3. Several of those channels are selected and saved as the integration's authorized channels.
|
||||
4. The connection check verifies the credential and every authorized channel, and posts a one-time confirmation message to each channel it has not confirmed yet.
|
||||
5. Features that deliver to Slack, such as [Alerts](/user-guide/tutorials/prowler-alerts), choose their destinations from the confirmed channels.
|
||||
6. Disconnecting removes the integration from Prowler and attempts to revoke Prowler's access at Slack.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The Slack integration is available only in **Prowler Cloud** and **Prowler Private Cloud**. Prowler Local Server does not serve the Slack endpoints at all, so the Slack card does not appear on the Integrations page and the management page redirects away.
|
||||
|
||||
Configuring and using the Slack integration requires the **Manage Integrations** permission. The integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group.
|
||||
|
||||
One Slack workspace connects per tenant. Approving Prowler again in the same workspace refreshes the stored credential and keeps the authorized channels, but it resets their confirmations and the connection state — the connection check has to be run again. Approving Prowler in a *different* workspace is refused until the current workspace is disconnected: a workspace is never swapped out silently.
|
||||
|
||||
## Permissions Prowler Requests in Slack
|
||||
|
||||
Slack shows a consent screen listing everything the Prowler app asks for. Prowler requests exactly four bot scopes:
|
||||
|
||||
| Scope | Why Prowler Requests It |
|
||||
|-------|-------------------------|
|
||||
| `chat:write` | Post the confirmation message, and any later notification, to the authorized channels. |
|
||||
| `chat:write.public` | Post to a public channel without first inviting the Prowler app to it. |
|
||||
| `channels:read` | List public channels for the channel selection and resolve the chosen ones. |
|
||||
| `groups:read` | List the private channels the Prowler app has been invited to, so they appear in the channel selection. |
|
||||
|
||||
Two of these read more broadly than they behave, and both are worth understanding before approving the app.
|
||||
|
||||
### What `chat:write.public` Does Not Grant
|
||||
|
||||
On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channels authorized on the integration.** The scope exists so that authorizing a public channel does not also require someone to invite the Prowler app to it first.
|
||||
|
||||
### Why a Private Channel Is Missing From the Channel List
|
||||
|
||||
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler` to it in Slack:
|
||||
|
||||
```text
|
||||
/invite @Prowler
|
||||
```
|
||||
|
||||
That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list.
|
||||
|
||||
## Connecting a Slack Workspace
|
||||
|
||||
To connect a Slack workspace to Prowler Cloud:
|
||||
|
||||
1. Navigate to **Integrations** in Prowler Cloud.
|
||||
2. Locate the **Slack** card and click **Manage**.
|
||||
|
||||

|
||||
|
||||
3. Click **Add to Slack**.
|
||||
|
||||

|
||||
|
||||
4. In Slack, select the workspace to connect and approve the permissions listed on the consent screen.
|
||||
5. Slack returns to Prowler Cloud, which completes the install and shows the connected workspace.
|
||||
|
||||

|
||||
|
||||
The connected card reports the workspace name and a **Not checked yet** status: the connection is checked against the authorized channels, and none are authorized at this point. Authorizing them is the next step. Once at least one channel is authorized, **Test connection** verifies the credential and every authorized channel, and confirms the ones not confirmed yet.
|
||||
|
||||
<Note>
|
||||
Declining the consent screen creates nothing. Prowler reports that the workspace was not connected and offers to start again.
|
||||
</Note>
|
||||
|
||||
## Authorizing Destination Channels
|
||||
|
||||
Prowler posts to the channels authorized on the integration. Several channels can be authorized at once, and once the connection check has confirmed them they are the pool every consumer of the integration draws from: an [Alert](/user-guide/tutorials/prowler-alerts) picks its Slack destinations from the confirmed channels, never from the whole workspace.
|
||||
|
||||
1. Open the **Destination channels** selection. It lists the workspace's public channels, plus the private channels the Prowler app has been invited to, each marked **Private**.
|
||||
|
||||

|
||||
|
||||
2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance.
|
||||
3. Click **Save channels**.
|
||||
|
||||
Prowler validates the selection against Slack and derives each channel name itself, so a recorded name can never drift from the channel it belongs to. Once the set is saved, the page reports where Prowler posts and runs the connection check over it.
|
||||
|
||||
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**.
|
||||
|
||||
A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out.
|
||||
|
||||
Saving a new selection replaces the authorized set: channels left out of it stop being authorized, and channels added to it are authorized but not yet confirmed. Changing which channels are in the set also resets the integration's connection state, so the check runs again over the new set — reordering the same channels does not. Saving an empty selection leaves the integration with no authorized channels, and **Test connection** cannot be run again until at least one channel is authorized.
|
||||
|
||||
<Warning>
|
||||
Removing a channel from the authorized set also removes it from every Alert that targeted it. Those Alerts keep their filters, frequency, and email recipients, and future delivery to that channel simply stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Disconnecting the integration has the same effect on every channel it had authorized. Restoring delivery means authorizing and confirming the channel again here, then selecting it again on each Alert.
|
||||
</Warning>
|
||||
|
||||
### Confirming the Authorized Channels
|
||||
|
||||
A channel becomes usable as a destination once the connection check has confirmed it. Click **Test connection**: it verifies the stored credential and every authorized channel, and posts a one-time message to each channel it has not confirmed yet.
|
||||
|
||||
```text
|
||||
✅ Prowler connection verified. Notifications will be delivered to this channel.
|
||||
```
|
||||
|
||||
Later checks never post that message again to a channel that is already confirmed, so it arrives once per channel. The integration reports as connected only when every check and every required confirmation succeeded; a failure names the channel that failed. The check needs at least one authorized channel — with none authorized, it cannot be run yet.
|
||||
|
||||
Confirmation is what makes a channel selectable elsewhere in Prowler Cloud. A channel authorized a moment ago is missing from an Alert's channel list until a connection check confirms it.
|
||||
|
||||
## Disconnecting a Slack Workspace
|
||||
|
||||
Disconnecting removes the integration from Prowler **and** attempts to revoke Prowler's access at Slack.
|
||||
|
||||
1. On the Slack management page, click **Disconnect**.
|
||||
2. Review the confirmation, then click **Disconnect workspace**.
|
||||
|
||||

|
||||
|
||||
The page returns to its unconnected state, ready for a new install.
|
||||
|
||||
### What Revocation Means
|
||||
|
||||
Revocation is attempted at Slack, and it is best-effort:
|
||||
|
||||
* **Revocation succeeded:** The stored credential no longer grants Prowler anything, and the integration is gone from Prowler.
|
||||
* **Revocation failed:** The integration and the stored credential are gone from Prowler either way, so there is nothing to retry. Slack did not confirm the revocation, which means the Prowler app may still be installed in the workspace. Remove it from that workspace's Slack app settings.
|
||||
* **Revocation unreported:** Slack's answer carried no outcome either way. The integration is gone from Prowler, and the disconnect is reported without any claim about revocation. When certainty matters, check the workspace's Slack app settings and remove the Prowler app if it is still installed.
|
||||
|
||||
Prowler reports the outcome it received: a failed revocation always names the manual cleanup step, and an unreported one is never presented as revoked.
|
||||
|
||||
<Warning>
|
||||
Disconnecting cannot be undone, and it removes the Slack channels from every Alert that targeted them. Reconnecting means approving Prowler in Slack again, authorizing the destination channels again, confirming them with a connection check, and selecting them again on each Alert that posts to Slack.
|
||||
</Warning>
|
||||
|
||||
## Integration Status
|
||||
|
||||
The Slack management page reports the state of the connection and offers these actions:
|
||||
|
||||
| Button | Purpose | Notes |
|
||||
|--------|---------|-------|
|
||||
| **Test connection** | Verify the credential and every authorized channel, and confirm the ones not confirmed yet | Posts the confirmation message once per channel and updates the last-checked time. Cannot be run until at least one channel is authorized |
|
||||
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
|
||||
| **Save channels** | Record the selected channels as the integration's authorized set | Enabled once the selection differs from the authorized set |
|
||||
| **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Slack Is Not Available in This Environment Yet
|
||||
|
||||
The Prowler Slack app is not configured for the deployment being used, so no workspace can be connected. This resolves without any action on the tenant's side — the page starts working as soon as the app is configured.
|
||||
|
||||
### A Private Channel Does Not Appear in the Channel List
|
||||
|
||||
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
|
||||
|
||||
### Connection Test Fails
|
||||
|
||||
* Confirm every authorized channel still exists and has not been archived. A failure names the channel Slack refused, and the integration reports as connected only when every authorized channel passes.
|
||||
* For a private authorized channel, confirm the Prowler app is still a member of it.
|
||||
* Confirm the Prowler app is still installed in the workspace.
|
||||
|
||||
### A Channel Is Missing From an Alert's Channel List
|
||||
|
||||
The channel is authorized here but not confirmed yet. Click **Test connection**: it confirms every authorized channel it has not confirmed, and confirmed channels become selectable on Alerts.
|
||||
|
||||
### Prowler's Access Has Been Revoked
|
||||
|
||||
When Slack stops accepting the stored credential — because a workspace administrator revoked it, or the app was removed from the workspace — Prowler reports the workspace as disconnected and offers **Reconnect to Slack**. Approving Prowler in Slack again restores access.
|
||||
|
||||
### The Connection Check Fails on a Channel
|
||||
|
||||
* Check the outcome reported on the page: it names the channel Slack refused and the reason Slack gave — an archived or deleted channel surfaces here rather than failing silently.
|
||||
* Confirm that channel is still one of the intended destinations, and that it has not been archived or deleted in Slack.
|
||||
* For a private channel, confirm the Prowler app is still a member of it.
|
||||
* One unreachable channel is enough to report the integration as not connected, so removing a retired channel from the authorized set clears the failure — bearing in mind that removing it also removes it from every Alert that targeted it.
|
||||
@@ -31,8 +31,6 @@
|
||||
"ec2:GetInstanceMetadataDefaults",
|
||||
"ecr:Describe*",
|
||||
"ecr:GetRegistryScanningConfiguration",
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"elasticfilesystem:DescribeBackupPolicy",
|
||||
"glue:GetConnections",
|
||||
"glue:GetSecurityConfiguration*",
|
||||
@@ -44,7 +42,6 @@
|
||||
"lightsail:GetRelationalDatabases",
|
||||
"macie2:GetMacieSession",
|
||||
"macie2:GetAutomatedDiscoveryConfiguration",
|
||||
"rolesanywhere:ListProfiles",
|
||||
"rolesanywhere:ListTagsForResource",
|
||||
"rolesanywhere:ListTrustAnchors",
|
||||
"s3:GetAccountPublicAccessBlock",
|
||||
|
||||
@@ -203,8 +203,6 @@ Resources:
|
||||
- "ec2:GetInstanceMetadataDefaults"
|
||||
- "ecr:Describe*"
|
||||
- "ecr:GetRegistryScanningConfiguration"
|
||||
- "ecr:BatchGetImage"
|
||||
- "ecr:GetDownloadUrlForLayer"
|
||||
- "elasticfilesystem:DescribeBackupPolicy"
|
||||
- "glue:GetConnections"
|
||||
- "glue:GetSecurityConfiguration*"
|
||||
@@ -215,7 +213,6 @@ Resources:
|
||||
- "lightsail:GetRelationalDatabases"
|
||||
- "macie2:GetMacieSession"
|
||||
- "macie2:GetAutomatedDiscoveryConfiguration"
|
||||
- "rolesanywhere:ListProfiles"
|
||||
- "rolesanywhere:ListTagsForResource"
|
||||
- "rolesanywhere:ListTrustAnchors"
|
||||
- "s3:GetAccountPublicAccessBlock"
|
||||
@@ -472,8 +469,6 @@ Resources:
|
||||
- "ec2:GetInstanceMetadataDefaults"
|
||||
- "ecr:Describe*"
|
||||
- "ecr:GetRegistryScanningConfiguration"
|
||||
- "ecr:BatchGetImage"
|
||||
- "ecr:GetDownloadUrlForLayer"
|
||||
- "elasticfilesystem:DescribeBackupPolicy"
|
||||
- "glue:GetConnections"
|
||||
- "glue:GetSecurityConfiguration*"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Bedrock Agent ARNs are now built from the audited partition instead of a hardcoded `arn:aws:`, so findings in GovCloud and China carry a resolvable ARN and `--resource-arn` scoping matches agents in those partitions.
|
||||
@@ -1 +0,0 @@
|
||||
`bedrock_guardrail_contextual_grounding_filter_enabled`, `bedrock_custom_model_encrypted_with_cmk`, `bedrock_knowledge_base_encrypted_with_cmk` and `bedrock_agent_role_not_shared_across_agents` are four new AWS Bedrock checks covering guardrail contextual grounding, custom model encryption, knowledge-base data-source encryption, and non-shared agent execution roles.
|
||||
@@ -1 +0,0 @@
|
||||
`ecr_repository_image_no_secrets` check for AWS provider, scanning the latest ECR repository image's configuration and filesystem layers for hardcoded secrets
|
||||
@@ -1 +0,0 @@
|
||||
Add the `iam_workload_identity_pool_provider_attribute_condition` check to flag GCP Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals
|
||||
@@ -1 +0,0 @@
|
||||
Add the `rolesanywhere_profile_restricts_session_permissions` check to flag AWS IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies
|
||||
@@ -1 +0,0 @@
|
||||
`ske_cluster_no_public_endpoint` check for STACKIT provider, flagging SKE clusters whose Kubernetes API endpoint is reachable from the whole internet because the ACL extension is disabled or its allowed CIDR list contains `0.0.0.0/0` or `::/0`
|
||||
@@ -1 +0,0 @@
|
||||
`vpc_security_group_open_egress` check for Huawei Cloud provider: VPC security groups do not allow open egress to the internet
|
||||
@@ -110,9 +110,7 @@
|
||||
"eks_cluster_kms_cmk_encryption_in_secrets_enabled",
|
||||
"dynamodb_tables_kms_cmk_encryption_enabled",
|
||||
"sns_topics_kms_encryption_at_rest_enabled",
|
||||
"sqs_queues_server_side_encryption_enabled",
|
||||
"bedrock_custom_model_encrypted_with_cmk",
|
||||
"bedrock_knowledge_base_encrypted_with_cmk"
|
||||
"sqs_queues_server_side_encryption_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -640,12 +638,10 @@
|
||||
"Section": "AI Application Security",
|
||||
"SubSection": "Output Validation",
|
||||
"Service": "bedrock",
|
||||
"Type": "Automated"
|
||||
"Type": "Manual"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"bedrock_guardrail_contextual_grounding_filter_enabled"
|
||||
]
|
||||
"Checks": []
|
||||
},
|
||||
{
|
||||
"Id": "AISF-AI-10",
|
||||
@@ -656,12 +652,10 @@
|
||||
"Section": "AI Application Security",
|
||||
"SubSection": "RAG Security",
|
||||
"Service": "bedrock",
|
||||
"Type": "Automated"
|
||||
"Type": "Manual"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"bedrock_knowledge_base_encrypted_with_cmk"
|
||||
]
|
||||
"Checks": []
|
||||
},
|
||||
{
|
||||
"Id": "AISF-AI-11",
|
||||
@@ -686,12 +680,10 @@
|
||||
"Section": "AI Application Security",
|
||||
"SubSection": "Agent Governance",
|
||||
"Service": "bedrock",
|
||||
"Type": "Automated"
|
||||
"Type": "Manual"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"bedrock_agent_role_not_shared_across_agents"
|
||||
]
|
||||
"Checks": []
|
||||
},
|
||||
{
|
||||
"Id": "AISF-AGENT-02",
|
||||
|
||||
@@ -49,7 +49,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.40.0"
|
||||
prowler_version = "5.39.2"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -3847,7 +3847,6 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -3857,9 +3856,7 @@
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
@@ -3873,7 +3870,6 @@
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
@@ -3956,7 +3952,6 @@
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-south-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
@@ -4639,7 +4634,6 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-south-1",
|
||||
"ap-southeast-2",
|
||||
"eu-west-1",
|
||||
"us-east-1",
|
||||
"us-west-2"
|
||||
@@ -4784,9 +4778,7 @@
|
||||
"cn-north-1",
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-eusc": [
|
||||
"eusc-de-east-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
@@ -4971,7 +4963,6 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-south-1",
|
||||
"ap-south-2",
|
||||
"ap-southeast-1",
|
||||
@@ -4980,8 +4971,6 @@
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
@@ -6242,16 +6231,6 @@
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"invoicing": {
|
||||
"regions": {
|
||||
"aws": [],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [
|
||||
"eusc-de-east-1"
|
||||
],
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"iot": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
@@ -6462,6 +6441,56 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"iotevents": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [
|
||||
"cn-north-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-west-1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"iotevents-data": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [
|
||||
"cn-north-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-west-1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"iotfleetwise": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
@@ -7731,7 +7760,6 @@
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-west-1",
|
||||
@@ -8765,10 +8793,7 @@
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
]
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"odb": {
|
||||
@@ -8801,7 +8826,6 @@
|
||||
"omics": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-southeast-1",
|
||||
"eu-central-1",
|
||||
@@ -8809,7 +8833,6 @@
|
||||
"eu-west-2",
|
||||
"il-central-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
@@ -9003,7 +9026,6 @@
|
||||
"eu-south-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
@@ -9012,10 +9034,7 @@
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
]
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"outposts": {
|
||||
@@ -9056,6 +9075,21 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"panorama": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-west-1",
|
||||
"us-east-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"partnercentral-account": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
@@ -9205,7 +9239,6 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
@@ -9968,9 +10001,7 @@
|
||||
"cn-north-1",
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-eusc": [
|
||||
"eusc-de-east-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
@@ -10092,10 +10123,7 @@
|
||||
"us-west-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [
|
||||
"cn-north-1",
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
@@ -10786,9 +10814,7 @@
|
||||
"cn-north-1",
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-eusc": [
|
||||
"eusc-de-east-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
@@ -12554,9 +12580,7 @@
|
||||
"us-east-1"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [
|
||||
"eusc-de-east-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
@@ -13132,7 +13156,6 @@
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "bedrock_agent_role_not_shared_across_agents",
|
||||
"CheckTitle": "Bedrock Agent has a dedicated execution role",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "bedrock",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "ai_ml",
|
||||
"Description": "Every **Bedrock Agent** assumes the role in its `agentResourceRoleArn`. That role must belong to exactly one agent, so no agent inherits another's permissions.",
|
||||
"Risk": "A **shared execution role** gives every agent using it the union of all their permissions, so a low-trust agent reaching an untrusted tool holds the rights granted for a high-trust one, turning a single **prompt injection** into access it was never scoped for.\n\nIt also destroys **attribution**: **CloudTrail** records the role session, so an action cannot be traced to one agent.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_UpdateAgent.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "```yaml\nResources:\n ExampleResource:\n Type: AWS::Bedrock::Agent\n Properties:\n AgentName: example_resource\n AgentResourceRoleArn: arn:aws:iam::123456789012:role/AmazonBedrockExecutionRoleForAgents_example_resource # Critical: one role per agent, never shared\n```",
|
||||
"Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Agents**, then an agent sharing a role\n3. Create a new IAM role trusted by bedrock.amazonaws.com holding only that agent's permissions\n4. Scope its trust policy with aws:SourceArn set to the agent ARN\n5. Edit the agent to use the new role, keeping its other settings, then prepare the agent to cut a version\n6. Open **Aliases** and point every alias at the new version, since an alias left on the old version keeps invoking the shared role\n7. Repeat until each agent has its own role, then remove the unused permissions from the old role",
|
||||
"Terraform": "```hcl\nresource \"aws_bedrockagent_agent\" \"example_resource\" {\n agent_name = \"example_resource\"\n agent_resource_role_arn = aws_iam_role.example_resource_agent.arn # Critical: one role per agent, never shared\n foundation_model = \"example-model-id\"\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Give each Bedrock Agent its own execution role scoped to that agent's ARN with aws:SourceArn. Repoint the draft with UpdateAgent, passing every field it already has because omitted ones are not preserved, then PrepareAgent and UpdateAgentAlias to move each alias onto the new version: an alias left on the old version keeps invoking the shared role.",
|
||||
"Url": "https://hub.prowler.com/check/bedrock_agent_role_not_shared_across_agents"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"gen-ai",
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_agent_role_least_privilege"
|
||||
],
|
||||
"Notes": "Reports one finding per AGENT, not per role, so a role shared by three agents produces three findings each naming the other two. Sharing is counted across the whole account inventory, so a partial scan can under-report. An agent whose role ARN could not be retrieved from GetAgent returns MANUAL rather than PASS, because an unknown role can be neither confirmed nor ruled out as shared, and it is also excluded from every other agent's share count."
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
|
||||
bedrock_agent_client,
|
||||
)
|
||||
|
||||
SHARED_ROLE_AGENT_COUNT = 2
|
||||
|
||||
|
||||
class bedrock_agent_role_not_shared_across_agents(Check):
|
||||
"""Ensure each Bedrock Agent has a dedicated execution role.
|
||||
|
||||
A shared `agentResourceRoleArn` means every agent using it inherits the
|
||||
union of all their permissions, so the least-privileged agent in the set
|
||||
still holds the rights of the most privileged one. It also destroys
|
||||
attribution: CloudTrail records the role session, so an action taken with
|
||||
that role cannot be tied back to a single agent.
|
||||
|
||||
Every role an agent can run under counts, not only the working draft's.
|
||||
GetAgent returns the draft, but an agent version is an immutable snapshot
|
||||
that keeps the role it was cut with, and an alias routes invocations at a
|
||||
specific version — so a deployed version can still hold a role the draft no
|
||||
longer has, and sharing it is the same exposure. Only versions an alias
|
||||
routes to are considered, since a version no alias points at cannot be
|
||||
invoked. An alias routing at DRAFT resolves to the draft role already read.
|
||||
|
||||
Sharing is judged against the whole account inventory, so the verdict is only
|
||||
as complete as that inventory. Dedication cannot be asserted while any part of
|
||||
the picture is missing — an unlisted Region, an agent whose own role could
|
||||
not be read, or an agent whose deployed versions could not be listed may hold
|
||||
the same role. A role already seen on two agents is shared whatever else is
|
||||
missing, so FAIL survives an incomplete inventory.
|
||||
|
||||
A scan scoped with ``--resource-arn`` narrows which agents are REPORTED on, not
|
||||
which agents count towards sharing: the role index is built from the complete
|
||||
account inventory, so selecting one of two agents that share a role still FAILs.
|
||||
|
||||
- PASS: No role this agent holds is used by any other agent, every Region's
|
||||
agent inventory was listed, every discovered agent's role was readable, and
|
||||
every agent's deployed versions were listed.
|
||||
- FAIL: Two or more agents hold the same role, on the draft or on a deployed
|
||||
version; the other agents holding it are named in the message, and the
|
||||
version is named when the sharing is through one.
|
||||
- MANUAL: This agent's own execution role could not be retrieved from
|
||||
GetAgent; or its roles look dedicated but another agent's role or deployed
|
||||
versions are unknown, or a Region could not be listed; or ListAgents failed
|
||||
for a Region, which therefore contributed no agents at all.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
incomplete_regions = sorted(bedrock_agent_client.agents_scan_errors)
|
||||
for region, error in sorted(bedrock_agent_client.agents_scan_errors.items()):
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource={"region": region}
|
||||
)
|
||||
report.region = region
|
||||
report.resource_id = "agent/unknown"
|
||||
report.resource_arn = f"arn:{bedrock_agent_client.audited_partition}:bedrock:{region}:{bedrock_agent_client.audited_account}:agent/unknown"
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Bedrock Agents could not be listed in region {region} ({error}); verify manually that no execution role is shared between agents."
|
||||
findings.append(report)
|
||||
|
||||
# Keyed on agent ARN, not name, so two same-named agents still count
|
||||
# twice. An agent is indexed under every role it holds, because a
|
||||
# deployed version keeps the role it was cut with: sharing through a
|
||||
# version is the same exposure as sharing through the draft. Indexed over
|
||||
# all_agents, not the filtered agents; findings are emitted from the
|
||||
# filtered set below.
|
||||
agents_by_role = {}
|
||||
unresolved_agents = []
|
||||
for agent in bedrock_agent_client.all_agents.values():
|
||||
entry = (agent.arn, agent.name or agent.id)
|
||||
for role_arn in self._roles_held_by(agent):
|
||||
if entry not in agents_by_role.setdefault(role_arn, []):
|
||||
agents_by_role[role_arn].append(entry)
|
||||
# Both gaps are recorded, not just the first: an agent can have an
|
||||
# unreadable draft role and an unlistable version inventory at once,
|
||||
# and each independently keeps another agent from being called
|
||||
# dedicated.
|
||||
if not agent.detail_retrieved or not agent.role_arn:
|
||||
unresolved_agents.append(
|
||||
f"the execution role of {agent.name or agent.id}"
|
||||
)
|
||||
if not agent.versions_listed:
|
||||
unresolved_agents.append(
|
||||
f"deployed versions of {agent.name or agent.id}"
|
||||
)
|
||||
|
||||
# Dedication can only be asserted from a complete picture: an unlisted
|
||||
# Region, an agent whose role could not be read, or an agent whose
|
||||
# deployed versions could not be listed may hold the same role.
|
||||
incomplete = sorted(unresolved_agents) + [
|
||||
f"agents in region {region}" for region in incomplete_regions
|
||||
]
|
||||
|
||||
for agent in bedrock_agent_client.agents.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=agent)
|
||||
name = agent.name or agent.id
|
||||
|
||||
if not agent.detail_retrieved or not agent.role_arn:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Bedrock Agent {name} execution role could not be retrieved in region {agent.region}; verify manually that no other agent shares it."
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
# Any role this agent holds, on the draft or on a deployed version,
|
||||
# is a finding when another agent holds it too. Reported on the
|
||||
# lowest-sorted shared role for determinism across scans.
|
||||
shared_roles = sorted(
|
||||
role_arn
|
||||
for role_arn in self._roles_held_by(agent)
|
||||
if len(agents_by_role.get(role_arn, [])) >= SHARED_ROLE_AGENT_COUNT
|
||||
)
|
||||
if shared_roles:
|
||||
role_arn = shared_roles[0]
|
||||
others = sorted(
|
||||
other_name
|
||||
for other_arn, other_name in agents_by_role[role_arn]
|
||||
if other_arn != agent.arn
|
||||
)
|
||||
through = (
|
||||
""
|
||||
if role_arn == agent.role_arn
|
||||
else f" through deployed version {sorted(version for version, version_role in agent.version_role_arns.items() if version_role == role_arn)[0]}"
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Bedrock Agent {name} shares execution role {role_arn}{through} with {', '.join(others)} in region {agent.region}, so each agent inherits the union of their permissions and CloudTrail cannot attribute an action to one of them."
|
||||
elif incomplete:
|
||||
report.status = "MANUAL"
|
||||
# Each entry already reads as its own subject, because they are
|
||||
# not all roles: an entry is an agent whose role is unknown, the
|
||||
# deployed versions of an agent, or the agents of a Region that
|
||||
# could not be listed.
|
||||
report.status_extended = f"Bedrock Agent {name} execution role is used by no other agent whose role could be read in region {agent.region}, but {', '.join(incomplete)} could not be read; verify manually that none of them shares it."
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Bedrock Agent {name} has a dedicated execution role in region {agent.region}."
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
def _roles_held_by(self, agent) -> set:
|
||||
"""Collect every execution role an agent can run under.
|
||||
|
||||
Args:
|
||||
agent: The Bedrock Agent to inspect.
|
||||
|
||||
Returns:
|
||||
The working draft's role plus the role of each deployed version an
|
||||
alias routes to, skipping any that could not be read.
|
||||
"""
|
||||
roles = set()
|
||||
if agent.detail_retrieved and agent.role_arn:
|
||||
roles.add(agent.role_arn)
|
||||
roles.update(
|
||||
role_arn for role_arn in agent.version_role_arns.values() if role_arn
|
||||
)
|
||||
return roles
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "bedrock_custom_model_encrypted_with_cmk",
|
||||
"CheckTitle": "Bedrock custom model is encrypted with a customer-managed KMS key",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Data Encryption"
|
||||
],
|
||||
"ServiceName": "bedrock",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "ai_ml",
|
||||
"Description": "**Bedrock custom models** produced by model customization can be encrypted at rest with a customer-managed KMS key rather than resting under an AWS-owned key the organization cannot audit, rotate, or revoke.",
|
||||
"Risk": "A **fine-tuned model** is derived from the training data used to build it and can leak that data through inference.\n\nUnder an **AWS-owned key**, the organization has no key policy to restrict who may decrypt the artifacts, no customer-controlled rotation, no **CloudTrail** record of key usage, and no way to revoke access to the model by disabling a key.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-custom-job.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GetCustomModel.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Custom models**, then create a new customization job\n3. Under encryption, choose a customer-managed KMS key instead of the default\n4. Grant the customization service role kms:Decrypt, kms:GenerateDataKey and kms:DescribeKey on that key\n5. Re-run customization, because the key of an existing custom model cannot be changed in place",
|
||||
"Terraform": "```hcl\nresource \"aws_bedrock_custom_model\" \"example_resource\" {\n custom_model_name = \"example-custom-model\"\n job_name = \"example-customization-job\"\n base_model_identifier = \"arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-express-v1\"\n role_arn = \"arn:aws:iam::123456789012:role/example-bedrock-customization-role\"\n custom_model_kms_key_id = \"arn:aws:kms:us-east-1:123456789012:key/example-key-id\" # Critical: without this the model is encrypted with an AWS owned key\n\n hyperparameters = {\n epochCount = \"1\"\n }\n\n output_data_config {\n s3_uri = \"s3://example-bucket/output/\"\n }\n\n training_data_config {\n s3_uri = \"s3://example-bucket/training/\"\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Choose a customer-managed KMS key when creating each model customization job so the resulting custom model rests under a key with an auditable policy and rotation. The key cannot be changed after the model is created, so re-run customization for existing models.",
|
||||
"Url": "https://hub.prowler.com/check/bedrock_custom_model_encrypted_with_cmk"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"gen-ai",
|
||||
"encryption"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_prompt_encrypted_with_cmk"
|
||||
],
|
||||
"Notes": "Reports one finding per custom model, reading modelKmsKeyArn from GetCustomModel because ListCustomModels summaries do not carry it. A model whose detail could not be retrieved returns MANUAL rather than PASS, because an absent key ARN means the answer is unknown, not that no key is set."
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
|
||||
|
||||
|
||||
class bedrock_custom_model_encrypted_with_cmk(Check):
|
||||
"""Ensure Bedrock custom models are encrypted with a customer-managed KMS key.
|
||||
|
||||
- PASS: GetCustomModel returns a `modelKmsKeyArn`, so the model artifacts
|
||||
are encrypted with a key the account controls.
|
||||
- FAIL: No `modelKmsKeyArn` is set, so the model is encrypted with an
|
||||
AWS-owned key that the organization cannot audit, rotate, or revoke.
|
||||
- MANUAL: GetCustomModel failed, so the key could not be retrieved and an
|
||||
absent value cannot be read as "no key"; or ListCustomModels failed for a
|
||||
region, so that region's custom models are unknown rather than absent.
|
||||
|
||||
Only models this account owns are audited (`ListCustomModels` is called with
|
||||
`isOwned=True`): the KMS key of a model shared in through Resource Access
|
||||
Manager belongs to the owning account and cannot be changed here.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for region, error in sorted(bedrock_client.custom_models_scan_errors.items()):
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource={"region": region}
|
||||
)
|
||||
report.region = region
|
||||
report.resource_id = "custom-model/unknown"
|
||||
report.resource_arn = f"arn:{bedrock_client.audited_partition}:bedrock:{region}:{bedrock_client.audited_account}:custom-model/unknown"
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Bedrock custom models could not be listed in region {region} ({error}); verify manually that every custom model uses a customer-managed KMS key."
|
||||
findings.append(report)
|
||||
|
||||
for model in bedrock_client.custom_models.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=model)
|
||||
|
||||
if not model.detail_retrieved:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Bedrock custom model {model.name} encryption configuration could not be retrieved in region {model.region}; verify manually that it uses a customer-managed KMS key."
|
||||
elif model.kms_key_arn:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Bedrock custom model {model.name} is encrypted with a customer-managed KMS key in region {model.region}."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Bedrock custom model {model.name} is not encrypted with a customer-managed KMS key in region {model.region}, so the fine-tuned weights rest under an AWS-owned key the organization cannot audit or revoke."
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "bedrock_guardrail_contextual_grounding_filter_enabled",
|
||||
"CheckTitle": "Bedrock guardrail blocks ungrounded and irrelevant model responses",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "bedrock",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "ai_ml",
|
||||
"Description": "**Bedrock guardrails** can attach a `contextualGroundingPolicy` whose filters score each response for `GROUNDING` (supported by the retrieved source) and `RELEVANCE` (answers the question asked). Both filter types must be enabled, set to `action: BLOCK`, and carry a `threshold` above 0.",
|
||||
"Risk": "Without both filters the model can return confident, unsupported answers that a **RAG application** presents as sourced fact.\n\nThree settings make a configured filter **inert**: `enabled: false` runs no evaluation, so a `BLOCK` action and tuned threshold sit unused; `action: NONE` scores the response and reports it in the trace while still delivering it; a `threshold` of 0 can never be tripped.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-contextual-grounding-check.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GuardrailContextualGroundingFilterConfig.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws bedrock update-guardrail --guardrail-identifier example-guardrail-id --name example-guardrail --blocked-input-messaging \"Sorry, I cannot answer that.\" --blocked-outputs-messaging \"Sorry, I cannot answer that.\" --contextual-grounding-policy-config '{\"filtersConfig\":[{\"type\":\"GROUNDING\",\"threshold\":0.75,\"action\":\"BLOCK\",\"enabled\":true},{\"type\":\"RELEVANCE\",\"threshold\":0.75,\"action\":\"BLOCK\",\"enabled\":true}]}'",
|
||||
"NativeIaC": "```yaml\nResources:\n ExampleResource:\n Type: AWS::Bedrock::Guardrail\n Properties:\n Name: example_resource\n BlockedInputMessaging: Sorry, I cannot answer that.\n BlockedOutputsMessaging: Sorry, I cannot answer that.\n ContextualGroundingPolicyConfig:\n FiltersConfig:\n - Type: GROUNDING\n Threshold: 0.75\n Action: BLOCK # Critical: NONE only reports, it does not block\n Enabled: true # Critical: false runs no evaluation at all\n - Type: RELEVANCE\n Threshold: 0.75\n Action: BLOCK\n Enabled: true\n```",
|
||||
"Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Guardrails**, then the guardrail\n3. Edit the contextual grounding check\n4. Enable both the grounding and the relevance filter\n5. Set each threshold above 0 to a value tuned for your corpus\n6. Set each action to Block rather than None\n7. Save and create a new guardrail version",
|
||||
"Terraform": "```hcl\nresource \"awscc_bedrock_guardrail\" \"example_resource\" {\n name = \"example-guardrail\"\n blocked_input_messaging = \"Sorry, I cannot answer that.\"\n blocked_outputs_messaging = \"Sorry, I cannot answer that.\"\n\n contextual_grounding_policy_config = {\n filters_config = [\n {\n type = \"GROUNDING\"\n threshold = 0.75 # Critical: 0 can never be tripped\n action = \"BLOCK\" # Critical: NONE scores without blocking\n enabled = true # Critical: when false the evaluation never runs\n },\n {\n type = \"RELEVANCE\"\n threshold = 0.75\n action = \"BLOCK\"\n enabled = true\n },\n ]\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure both a GROUNDING and a RELEVANCE contextual grounding filter on every guardrail used by a retrieval-augmented application, set each action to BLOCK, and tune each threshold above 0 against a labelled sample rather than leaving it at the minimum.",
|
||||
"Url": "https://hub.prowler.com/check/bedrock_guardrail_contextual_grounding_filter_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_guardrail_prompt_attack_filter_enabled"
|
||||
],
|
||||
"Notes": "Reports one finding per guardrail, reading contextualGroundingPolicy.filters from GetGuardrail. A guardrail whose detail could not be retrieved returns MANUAL rather than PASS, because an absent policy means the answer is unknown, not that the control is satisfied. enabled and action are both optional members with no documented default, so a filter that is otherwise compliant but omits either returns MANUAL rather than being read as blocking or not; an explicit enabled: false or a non-BLOCK action is a definite finding and FAILs."
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
|
||||
|
||||
REQUIRED_FILTER_TYPES = frozenset({"GROUNDING", "RELEVANCE"})
|
||||
BLOCKING_ACTION = "BLOCK"
|
||||
|
||||
|
||||
class bedrock_guardrail_contextual_grounding_filter_enabled(Check):
|
||||
"""Ensure Bedrock guardrails block ungrounded and irrelevant model responses.
|
||||
|
||||
- PASS: The guardrail configures both a GROUNDING and a RELEVANCE
|
||||
contextual grounding filter, each enabled, with action BLOCK and a
|
||||
threshold above 0.
|
||||
- FAIL: No contextual grounding policy is configured; one of the two
|
||||
required filter types is missing; a present filter carries
|
||||
enabled: false, so its evaluation never runs; a present filter uses
|
||||
action NONE, which scores and reports without blocking; or a present
|
||||
filter has a threshold of 0, which nothing can ever trip.
|
||||
- MANUAL: GetGuardrail failed, so the policy could not be retrieved and
|
||||
compliance cannot be asserted from an absent field; or a filter is
|
||||
otherwise compliant but omits enabled or action, leaving it unknown
|
||||
whether the evaluation runs or whether it blocks; or ListGuardrails failed
|
||||
for a Region, so that Region's guardrails are unknown rather than absent.
|
||||
|
||||
enabled and action are both optional members of
|
||||
GuardrailContextualGroundingFilter (only type and threshold are required)
|
||||
and AWS documents no default for either, so an omitted value is unknown
|
||||
rather than false or NONE. Reading an omitted enabled as false would FAIL
|
||||
filters that are in fact evaluating; reading an omitted action as NONE
|
||||
would assert a misconfiguration the response never stated, and would print
|
||||
the literal None into status_extended where it reads as an AWS enum value.
|
||||
Both are therefore reported MANUAL, matching how this check already treats
|
||||
a guardrail whose detail could not be retrieved. An explicit enabled: false
|
||||
or an explicit non-BLOCK action is a definite finding and still FAILs, as
|
||||
does a zero threshold, so an unknown never masks a real one.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for region, error in sorted(bedrock_client.guardrails_scan_errors.items()):
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource={"region": region}
|
||||
)
|
||||
report.region = region
|
||||
report.resource_id = "guardrail/unknown"
|
||||
report.resource_arn = f"arn:{bedrock_client.audited_partition}:bedrock:{region}:{bedrock_client.audited_account}:guardrail/unknown"
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Bedrock guardrails could not be listed in region {region} ({error}); verify manually that each one blocks ungrounded and irrelevant responses."
|
||||
findings.append(report)
|
||||
|
||||
for guardrail in bedrock_client.guardrails.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=guardrail)
|
||||
|
||||
if not guardrail.detail_retrieved:
|
||||
# GetGuardrail failed (permissions, throttling, transient
|
||||
# error). An absent policy is not evidence of a missing one.
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Bedrock Guardrail {guardrail.name} contextual grounding policy could not be retrieved in region {guardrail.region}; verify manually that GROUNDING and RELEVANCE filters block ungrounded responses."
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
filters_by_type = {
|
||||
filter.type: filter
|
||||
for filter in guardrail.contextual_grounding_filters
|
||||
if filter.type
|
||||
}
|
||||
|
||||
if not filters_by_type:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Bedrock Guardrail {guardrail.name} has no contextual grounding policy configured in region {guardrail.region}, so ungrounded and irrelevant responses are never detected."
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
missing_types = sorted(REQUIRED_FILTER_TYPES - set(filters_by_type))
|
||||
if missing_types:
|
||||
report.status = "FAIL"
|
||||
# Both required types can be missing at once, so the nouns follow
|
||||
# the count rather than assuming a single filter.
|
||||
noun = "filter" if len(missing_types) == 1 else "filters"
|
||||
classes = "that class" if len(missing_types) == 1 else "those classes"
|
||||
report.status_extended = f"Bedrock Guardrail {guardrail.name} contextual grounding policy is missing the {', '.join(missing_types)} {noun} in region {guardrail.region}, leaving {classes} of ungrounded response unchecked."
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
reasons = []
|
||||
unknown_types = []
|
||||
for filter_type in sorted(REQUIRED_FILTER_TYPES):
|
||||
filter = filters_by_type[filter_type]
|
||||
action = filter.action
|
||||
if filter.enabled is False:
|
||||
reasons.append(
|
||||
f"the {filter_type} filter is disabled, so its evaluation never runs regardless of its action or threshold"
|
||||
)
|
||||
elif action is not None and action != BLOCKING_ACTION:
|
||||
reasons.append(
|
||||
f"the {filter_type} filter uses action {action} instead of BLOCK, so it scores and reports without blocking"
|
||||
)
|
||||
elif not (filter.threshold or 0) > 0:
|
||||
reasons.append(
|
||||
f"the {filter_type} filter has a threshold of {filter.threshold}, which no response can ever trip"
|
||||
)
|
||||
elif action is None or filter.enabled is None:
|
||||
# Name only the attributes actually omitted: one of the two
|
||||
# may be present, and claiming both are missing describes a
|
||||
# response the guardrail did not return.
|
||||
missing = [
|
||||
attribute
|
||||
for attribute, value in (
|
||||
("enabled", filter.enabled),
|
||||
("action", action),
|
||||
)
|
||||
if value is None
|
||||
]
|
||||
unknown_types.append(
|
||||
f"{filter_type} filter omits {' and '.join(missing)}"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Bedrock Guardrail {guardrail.name} does not block ungrounded responses in region {guardrail.region}: {'; '.join(reasons)}."
|
||||
elif unknown_types:
|
||||
report.status = "MANUAL"
|
||||
subject = "it blocks" if len(unknown_types) == 1 else "they block"
|
||||
report.status_extended = f"Bedrock Guardrail {guardrail.name} has both required contextual grounding filters with a non-zero threshold in region {guardrail.region}, but the {', '.join(unknown_types)}, so whether {subject} is unknown; verify manually that the evaluation runs and blocks."
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Bedrock Guardrail {guardrail.name} blocks ungrounded and irrelevant responses with GROUNDING and RELEVANCE filters in region {guardrail.region}."
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "bedrock_knowledge_base_encrypted_with_cmk",
|
||||
"CheckTitle": "Bedrock knowledge base data source is encrypted with a customer-managed KMS key",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Data Encryption"
|
||||
],
|
||||
"ServiceName": "bedrock",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "ai_ml",
|
||||
"Description": "Each data source of a **Bedrock knowledge base** can set `serverSideEncryptionConfiguration.kmsKeyArn`, which encrypts the transient storage used while documents are chunked and embedded, rather than relying on an AWS-owned key.",
|
||||
"Risk": "**Knowledge base data sources** carry the private corpus a **RAG application** answers from, often the most sensitive content in the workload.\n\nUnder an **AWS-owned key**, there is no key policy restricting who may decrypt the ingestion storage, no account-controlled rotation, no **CloudTrail** record of key usage, and no way to revoke access by disabling a key.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-kb.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_GetDataSource.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws bedrock-agent update-data-source --knowledge-base-id example-kb-id --data-source-id example-ds-id --name example-data-source --data-source-configuration '{\"type\":\"S3\",\"s3Configuration\":{\"bucketArn\":\"arn:aws:s3:::example-bucket\"}}' --server-side-encryption-configuration '{\"kmsKeyArn\":\"arn:aws:kms:us-east-1:123456789012:key/example-key-id\"}'",
|
||||
"NativeIaC": "```yaml\nResources:\n ExampleResource:\n Type: AWS::Bedrock::DataSource\n Properties:\n Name: example_resource\n KnowledgeBaseId: ABCDE12345\n DataSourceConfiguration:\n Type: S3\n S3Configuration:\n BucketArn: arn:aws:s3:::example-bucket\n ServerSideEncryptionConfiguration:\n KmsKeyArn: arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab # Critical: omitting this uses an AWS-owned key\n```",
|
||||
"Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Knowledge bases**, then the knowledge base\n3. Select the data source and choose Edit\n4. Under transient data encryption, choose a customer-managed KMS key\n5. Grant the knowledge base service role kms:Decrypt and kms:GenerateDataKey on that key\n6. Save and re-sync the data source",
|
||||
"Terraform": "```hcl\nresource \"aws_bedrockagent_data_source\" \"example_resource\" {\n name = \"example_resource\"\n knowledge_base_id = \"ABCDE12345\"\n data_source_configuration {\n type = \"S3\"\n s3_configuration {\n bucket_arn = \"arn:aws:s3:::example-bucket\"\n }\n }\n server_side_encryption_configuration {\n kms_key_arn = \"arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab\" # Critical: omitting this uses an AWS-owned key\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set a customer-managed KMS key on every knowledge base data source so the transient ingestion storage rests under a key with an auditable policy, and grant the knowledge base service role only kms:Decrypt and kms:GenerateDataKey on it.",
|
||||
"Url": "https://hub.prowler.com/check/bedrock_knowledge_base_encrypted_with_cmk"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"gen-ai",
|
||||
"encryption"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_custom_model_encrypted_with_cmk"
|
||||
],
|
||||
"Notes": "Reports one finding per DATA SOURCE, not per knowledge base, because the key is configured on the data source; the knowledge base name is carried into the message. GetDataSource nests its payload under a top-level dataSource object. A data source whose detail could not be retrieved returns MANUAL rather than PASS, because an absent key ARN means the answer is unknown, not that no key is set."
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
|
||||
bedrock_agent_client,
|
||||
)
|
||||
|
||||
|
||||
class bedrock_knowledge_base_encrypted_with_cmk(Check):
|
||||
"""Ensure Bedrock knowledge base data sources are encrypted with a CMK.
|
||||
|
||||
One finding is reported per data source, because the key is configured on
|
||||
the data source rather than on the knowledge base.
|
||||
|
||||
- PASS: The data source sets `serverSideEncryptionConfiguration.kmsKeyArn`,
|
||||
so the transient storage used during ingestion is encrypted with a key
|
||||
the account controls.
|
||||
- FAIL: No `kmsKeyArn` is set, so the ingested documents rest under an
|
||||
AWS-owned key the organization cannot audit, rotate, or revoke.
|
||||
- MANUAL: GetDataSource failed, so the key could not be retrieved and an
|
||||
absent value cannot be read as "no key"; or ListDataSources failed for the
|
||||
knowledge base, so its data sources are unknown; or ListKnowledgeBases
|
||||
failed for a region, so the region's knowledge bases are unknown.
|
||||
|
||||
A knowledge base whose data sources could not be listed is reported against
|
||||
the knowledge base itself. Reporting nothing would drop it from the output
|
||||
entirely, which reads as "no data sources to flag" and is indistinguishable
|
||||
from a clean result.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for region, error in sorted(
|
||||
bedrock_agent_client.knowledge_bases_scan_errors.items()
|
||||
):
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource={"region": region}
|
||||
)
|
||||
report.region = region
|
||||
report.resource_id = "knowledge-base/unknown"
|
||||
report.resource_arn = f"arn:{bedrock_agent_client.audited_partition}:bedrock:{region}:{bedrock_agent_client.audited_account}:knowledge-base/unknown"
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Bedrock knowledge bases could not be listed in region {region} ({error}); verify manually that every knowledge base data source uses a customer-managed KMS key."
|
||||
findings.append(report)
|
||||
|
||||
for knowledge_base in bedrock_agent_client.knowledge_bases.values():
|
||||
if knowledge_base.data_sources_listed:
|
||||
continue
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=knowledge_base)
|
||||
report.status = "MANUAL"
|
||||
reason = (
|
||||
f" ({knowledge_base.data_sources_error})"
|
||||
if knowledge_base.data_sources_error
|
||||
else ""
|
||||
)
|
||||
report.status_extended = f"Bedrock knowledge base {knowledge_base.name} data sources could not be listed in region {knowledge_base.region}{reason}; verify manually that each one uses a customer-managed KMS key."
|
||||
findings.append(report)
|
||||
|
||||
for data_source in bedrock_agent_client.data_sources.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=data_source)
|
||||
knowledge_base = (
|
||||
data_source.knowledge_base_name or data_source.knowledge_base_id
|
||||
)
|
||||
|
||||
if not data_source.detail_retrieved:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Bedrock knowledge base {knowledge_base} data source {data_source.name} encryption configuration could not be retrieved in region {data_source.region}; verify manually that it uses a customer-managed KMS key."
|
||||
elif data_source.kms_key_arn:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Bedrock knowledge base {knowledge_base} data source {data_source.name} is encrypted with a customer-managed KMS key in region {data_source.region}."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Bedrock knowledge base {knowledge_base} data source {data_source.name} is not encrypted with a customer-managed KMS key in region {data_source.region}, so ingested documents rest under an AWS-owned key the organization cannot audit or revoke."
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -16,14 +16,10 @@ class Bedrock(AWSService):
|
||||
self.guardrails = {}
|
||||
self.guardrails_scanned_regions = set()
|
||||
self.guardrails_scan_errors = {}
|
||||
self.custom_models = {}
|
||||
self.custom_models_scan_errors = {}
|
||||
self.__threading_call__(self._get_model_invocation_logging_configuration)
|
||||
self.__threading_call__(self._list_guardrails)
|
||||
self.__threading_call__(self._get_guardrail, self.guardrails.values())
|
||||
self.__threading_call__(self._list_tags_for_resource, self.guardrails.values())
|
||||
self.__threading_call__(self._list_custom_models)
|
||||
self.__threading_call__(self._get_custom_model, self.custom_models.values())
|
||||
|
||||
def _get_model_invocation_logging_arn_template(self, region):
|
||||
return (
|
||||
@@ -60,7 +56,6 @@ class Bedrock(AWSService):
|
||||
)
|
||||
|
||||
def _list_guardrails(self, regional_client):
|
||||
"""List the guardrails in a region."""
|
||||
logger.info("Bedrock - Listing Guardrails...")
|
||||
try:
|
||||
paginator = regional_client.get_paginator("list_guardrails")
|
||||
@@ -77,11 +72,9 @@ class Bedrock(AWSService):
|
||||
)
|
||||
self.guardrails_scanned_regions.add(regional_client.region)
|
||||
except ClientError as error:
|
||||
code = error.response["Error"].get("Code", error.__class__.__name__)
|
||||
# ValidationException means Bedrock is unavailable in the region:
|
||||
# a definite "no guardrails", so it must not become a MANUAL finding.
|
||||
if code != "ValidationException":
|
||||
self.guardrails_scan_errors[regional_client.region] = code
|
||||
self.guardrails_scan_errors[regional_client.region] = error.response[
|
||||
"Error"
|
||||
].get("Code", error.__class__.__name__)
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
@@ -107,19 +100,6 @@ class Bedrock(AWSService):
|
||||
guardrail.prompt_attack_filter_strength = filter.get(
|
||||
"inputStrength", "NONE"
|
||||
)
|
||||
# Absent from the response when no grounding policy is configured.
|
||||
guardrail.contextual_grounding_filters = [
|
||||
ContextualGroundingFilter(
|
||||
type=filter.get("type"),
|
||||
threshold=filter.get("threshold"),
|
||||
action=filter.get("action"),
|
||||
enabled=filter.get("enabled"),
|
||||
)
|
||||
for filter in guardrail_info.get("contextualGroundingPolicy", {}).get(
|
||||
"filters", []
|
||||
)
|
||||
]
|
||||
guardrail.detail_retrieved = True
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
@@ -138,64 +118,6 @@ class Bedrock(AWSService):
|
||||
f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_custom_models(self, regional_client):
|
||||
"""List the customized models owned by the audited account.
|
||||
|
||||
isOwned=True is required: without it the response also carries models
|
||||
shared into this account through Resource Access Manager, whose KMS key
|
||||
the audited account does not own and cannot set, so auditing them
|
||||
produces a finding nobody here can remediate.
|
||||
"""
|
||||
logger.info("Bedrock - Listing Custom Models...")
|
||||
try:
|
||||
paginator = regional_client.get_paginator("list_custom_models")
|
||||
for page in paginator.paginate(isOwned=True):
|
||||
for model in page.get("modelSummaries", []):
|
||||
model_arn = model.get("modelArn", "")
|
||||
if model_arn and (
|
||||
not self.audit_resources
|
||||
or is_resource_filtered(model_arn, self.audit_resources)
|
||||
):
|
||||
self.custom_models[model_arn] = CustomModel(
|
||||
name=model.get("modelName", ""),
|
||||
arn=model_arn,
|
||||
region=regional_client.region,
|
||||
)
|
||||
except ClientError as error:
|
||||
code = error.response["Error"].get("Code", error.__class__.__name__)
|
||||
# ValidationException means Bedrock is unavailable in the region:
|
||||
# a definite "no models", so it must not become a MANUAL finding.
|
||||
if code != "ValidationException":
|
||||
self.custom_models_scan_errors[regional_client.region] = code
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
self.custom_models_scan_errors[regional_client.region] = (
|
||||
error.__class__.__name__
|
||||
)
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_custom_model(self, model):
|
||||
"""Fetch the KMS key a custom model's weights are encrypted with.
|
||||
|
||||
list_custom_models summaries do not carry modelKmsKeyArn, so a
|
||||
per-model GetCustomModel call is required.
|
||||
"""
|
||||
logger.info("Bedrock - Getting Custom Model...")
|
||||
try:
|
||||
model_info = self.regional_clients[model.region].get_custom_model(
|
||||
modelIdentifier=model.arn
|
||||
)
|
||||
model.kms_key_arn = model_info.get("modelKmsKeyArn")
|
||||
model.detail_retrieved = True
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{model.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class LoggingConfiguration(BaseModel):
|
||||
enabled: bool = False
|
||||
@@ -203,19 +125,6 @@ class LoggingConfiguration(BaseModel):
|
||||
s3_bucket: Optional[str] = None
|
||||
|
||||
|
||||
class ContextualGroundingFilter(BaseModel):
|
||||
"""One filter of a guardrail's contextualGroundingPolicy.
|
||||
|
||||
type and threshold are required by the API; enabled and action are optional
|
||||
and have no documented default, so an absent value means unknown.
|
||||
"""
|
||||
|
||||
type: Optional[str] = None
|
||||
threshold: Optional[float] = None
|
||||
action: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class Guardrail(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -224,20 +133,6 @@ class Guardrail(BaseModel):
|
||||
tags: Optional[list] = []
|
||||
sensitive_information_filter: bool = False
|
||||
prompt_attack_filter_strength: Optional[str] = None
|
||||
contextual_grounding_filters: list[ContextualGroundingFilter] = []
|
||||
# False when GetGuardrail failed: absent policy is unknown, not unset.
|
||||
detail_retrieved: bool = False
|
||||
|
||||
|
||||
class CustomModel(BaseModel):
|
||||
"""Model representing a Bedrock customized (fine-tuned) model."""
|
||||
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
kms_key_arn: Optional[str] = None
|
||||
# False when GetCustomModel failed: absent key is unknown, not unset.
|
||||
detail_retrieved: bool = False
|
||||
|
||||
|
||||
class BedrockAgent(AWSService):
|
||||
@@ -248,28 +143,13 @@ class BedrockAgent(AWSService):
|
||||
# Call AWSService's __init__
|
||||
super().__init__("bedrock-agent", provider)
|
||||
self.agents = {}
|
||||
# Every agent in the account, including those --resource-arn excluded. A check whose verdict
|
||||
# for one agent depends on another (role sharing) cannot read self.agents: the agent that
|
||||
# proves the sharing may be the one the operator filtered out. Same shape as
|
||||
# cloudwatch_service's all_log_groups/log_groups pair -- one object, two dicts.
|
||||
self.all_agents = {}
|
||||
self.prompts = {}
|
||||
self.knowledge_bases = {}
|
||||
self.data_sources = {}
|
||||
self.knowledge_bases_scan_errors = {}
|
||||
self.agents_scan_errors = {}
|
||||
self.prompt_scanned_regions: set = set()
|
||||
self.__threading_call__(self._list_agents)
|
||||
# Detail collection runs over the COMPLETE inventory: an out-of-scope agent's role still
|
||||
# determines whether an in-scope agent's role is shared.
|
||||
self.__threading_call__(self._get_agent, self.all_agents.values())
|
||||
self.__threading_call__(self._get_agent_version_roles, self.all_agents.values())
|
||||
self.__threading_call__(self._get_agent, self.agents.values())
|
||||
self.__threading_call__(self._list_prompts)
|
||||
self.__threading_call__(self._get_prompt, self.prompts.values())
|
||||
self.__threading_call__(self._list_tags_for_resource, self.agents.values())
|
||||
self.__threading_call__(self._list_knowledge_bases)
|
||||
self.__threading_call__(self._list_data_sources, self.knowledge_bases.values())
|
||||
self.__threading_call__(self._get_data_source, self.data_sources.values())
|
||||
|
||||
def _list_agents(self, regional_client):
|
||||
logger.info("Bedrock Agent - Listing Agents...")
|
||||
@@ -277,32 +157,20 @@ class BedrockAgent(AWSService):
|
||||
paginator = regional_client.get_paginator("list_agents")
|
||||
for page in paginator.paginate():
|
||||
for agent in page.get("agentSummaries", []):
|
||||
agent_arn = f"arn:{self.audited_partition}:bedrock:{regional_client.region}:{self.audited_account}:agent/{agent['agentId']}"
|
||||
agent_object = Agent(
|
||||
id=agent["agentId"],
|
||||
name=agent["agentName"],
|
||||
arn=agent_arn,
|
||||
guardrail_id=agent.get("guardrailConfiguration", {}).get(
|
||||
"guardrailIdentifier"
|
||||
),
|
||||
region=regional_client.region,
|
||||
)
|
||||
self.all_agents[agent_arn] = agent_object
|
||||
agent_arn = f"arn:aws:bedrock:{regional_client.region}:{self.audited_account}:agent/{agent['agentId']}"
|
||||
if not self.audit_resources or (
|
||||
is_resource_filtered(agent_arn, self.audit_resources)
|
||||
):
|
||||
self.agents[agent_arn] = agent_object
|
||||
except ClientError as error:
|
||||
code = error.response["Error"].get("Code", error.__class__.__name__)
|
||||
# ValidationException means Bedrock Agent is unavailable in the
|
||||
# region: a definite "no agents", so it must not become MANUAL.
|
||||
if code != "ValidationException":
|
||||
self.agents_scan_errors[regional_client.region] = code
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
self.agents[agent_arn] = Agent(
|
||||
id=agent["agentId"],
|
||||
name=agent["agentName"],
|
||||
arn=agent_arn,
|
||||
guardrail_id=agent.get("guardrailConfiguration", {}).get(
|
||||
"guardrailIdentifier"
|
||||
),
|
||||
region=regional_client.region,
|
||||
)
|
||||
except Exception as error:
|
||||
self.agents_scan_errors[regional_client.region] = error.__class__.__name__
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
@@ -318,86 +186,11 @@ class BedrockAgent(AWSService):
|
||||
try:
|
||||
agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id)
|
||||
agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn")
|
||||
agent.detail_retrieved = True
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
# PREPARED is the only status AWS documents as ready to invoke. CREATING and
|
||||
# UPDATING are in-flight, so their routed versions are unknown rather than
|
||||
# definitely active or inactive.
|
||||
TRANSITIONAL_ALIAS_STATUSES = {"CREATING", "UPDATING"}
|
||||
|
||||
@staticmethod
|
||||
def _is_alias_active(alias: dict) -> bool:
|
||||
"""Can this alias actually invoke the version it routes to?
|
||||
|
||||
Args:
|
||||
alias: One agentAliasSummaries entry from ListAgentAliases.
|
||||
|
||||
Returns:
|
||||
True only when the alias is prepared and does not reject invocations.
|
||||
"""
|
||||
if alias.get("aliasInvocationState") == "REJECT_INVOCATIONS":
|
||||
return False
|
||||
return alias.get("agentAliasStatus") == "PREPARED"
|
||||
|
||||
def _get_agent_version_roles(self, agent):
|
||||
"""Fetch the execution role of every agent version an ACTIVE alias routes to.
|
||||
|
||||
GetAgent returns only the working draft. An agent version is an
|
||||
immutable snapshot that keeps the role it was cut with, and an alias
|
||||
routes invocations at a specific version, so a deployed version can
|
||||
still hold a role the draft no longer has. Only versions an active alias
|
||||
routes to are fetched: a version nothing can invoke is not live
|
||||
exposure, so reporting on it would be a false FAIL.
|
||||
"""
|
||||
logger.info("Bedrock Agent - Getting Agent Version Roles...")
|
||||
try:
|
||||
client = self.regional_clients[agent.region]
|
||||
paginator = client.get_paginator("list_agent_aliases")
|
||||
routed_versions = set()
|
||||
inventory_complete = True
|
||||
for page in paginator.paginate(agentId=agent.id):
|
||||
for alias in page.get("agentAliasSummaries", []):
|
||||
if (
|
||||
alias.get("agentAliasStatus")
|
||||
in self.TRANSITIONAL_ALIAS_STATUSES
|
||||
):
|
||||
inventory_complete = False
|
||||
continue
|
||||
if not self._is_alias_active(alias):
|
||||
continue
|
||||
for route in alias.get("routingConfiguration", []):
|
||||
version = route.get("agentVersion")
|
||||
# agentVersion is an optional member of the routing
|
||||
# configuration, and DRAFT routes at the working draft
|
||||
# whose role GetAgent already captured.
|
||||
if version and version != "DRAFT":
|
||||
routed_versions.add(version)
|
||||
|
||||
for version in sorted(routed_versions):
|
||||
version_info = client.get_agent_version(
|
||||
agentId=agent.id, agentVersion=version
|
||||
)
|
||||
agent.version_role_arns[version] = version_info.get(
|
||||
"agentVersion", {}
|
||||
).get("agentResourceRoleArn")
|
||||
agent.versions_listed = inventory_complete
|
||||
except ClientError as error:
|
||||
agent.versions_error = error.response["Error"].get(
|
||||
"Code", error.__class__.__name__
|
||||
)
|
||||
logger.error(
|
||||
f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
agent.versions_error = error.__class__.__name__
|
||||
logger.error(
|
||||
f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_prompts(self, regional_client):
|
||||
"""List all prompts in a region."""
|
||||
logger.info("Bedrock Agent - Listing Prompts...")
|
||||
@@ -436,111 +229,6 @@ class BedrockAgent(AWSService):
|
||||
f"{prompt.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_knowledge_bases(self, regional_client):
|
||||
"""List the knowledge bases in a region."""
|
||||
logger.info("Bedrock Agent - Listing Knowledge Bases...")
|
||||
try:
|
||||
paginator = regional_client.get_paginator("list_knowledge_bases")
|
||||
for page in paginator.paginate():
|
||||
for knowledge_base in page.get("knowledgeBaseSummaries", []):
|
||||
knowledge_base_id = knowledge_base.get("knowledgeBaseId", "")
|
||||
if not knowledge_base_id:
|
||||
continue
|
||||
knowledge_base_arn = f"arn:{self.audited_partition}:bedrock:{regional_client.region}:{self.audited_account}:knowledge-base/{knowledge_base_id}"
|
||||
if not self.audit_resources or is_resource_filtered(
|
||||
knowledge_base_arn, self.audit_resources
|
||||
):
|
||||
self.knowledge_bases[knowledge_base_arn] = KnowledgeBase(
|
||||
id=knowledge_base_id,
|
||||
name=knowledge_base.get("name", ""),
|
||||
arn=knowledge_base_arn,
|
||||
region=regional_client.region,
|
||||
)
|
||||
except ClientError as error:
|
||||
code = error.response["Error"].get("Code", error.__class__.__name__)
|
||||
# ValidationException means Bedrock Agent is unavailable in the
|
||||
# region: a definite "none", so it must not become a MANUAL finding.
|
||||
if code != "ValidationException":
|
||||
self.knowledge_bases_scan_errors[regional_client.region] = code
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
self.knowledge_bases_scan_errors[regional_client.region] = (
|
||||
error.__class__.__name__
|
||||
)
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_data_sources(self, knowledge_base):
|
||||
"""List the data sources attached to one knowledge base.
|
||||
|
||||
A failure is recorded on the knowledge base itself: findings are per data
|
||||
source, so an unlisted knowledge base would otherwise vanish from the report.
|
||||
"""
|
||||
logger.info("Bedrock Agent - Listing Data Sources...")
|
||||
try:
|
||||
paginator = self.regional_clients[knowledge_base.region].get_paginator(
|
||||
"list_data_sources"
|
||||
)
|
||||
for page in paginator.paginate(knowledgeBaseId=knowledge_base.id):
|
||||
for data_source in page.get("dataSourceSummaries", []):
|
||||
data_source_id = data_source.get("dataSourceId", "")
|
||||
if not data_source_id:
|
||||
continue
|
||||
data_source_arn = (
|
||||
f"{knowledge_base.arn}/data-source/{data_source_id}"
|
||||
)
|
||||
# No filter here: the parent knowledge base was already
|
||||
# filtered on its own ARN, and this ARN is synthetic because
|
||||
# AWS exposes none for a data source, so it could never match
|
||||
# a user-supplied --resource-arn and would silently drop every
|
||||
# data source of an in-scope knowledge base.
|
||||
self.data_sources[data_source_arn] = KnowledgeBaseDataSource(
|
||||
id=data_source_id,
|
||||
name=data_source.get("name", ""),
|
||||
arn=data_source_arn,
|
||||
region=knowledge_base.region,
|
||||
knowledge_base_id=knowledge_base.id,
|
||||
knowledge_base_name=knowledge_base.name,
|
||||
)
|
||||
knowledge_base.data_sources_listed = True
|
||||
except ClientError as error:
|
||||
knowledge_base.data_sources_error = error.response["Error"].get(
|
||||
"Code", error.__class__.__name__
|
||||
)
|
||||
logger.error(
|
||||
f"{knowledge_base.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
knowledge_base.data_sources_error = error.__class__.__name__
|
||||
logger.error(
|
||||
f"{knowledge_base.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_data_source(self, data_source):
|
||||
"""Fetch the KMS key a data source's transient storage is encrypted with."""
|
||||
logger.info("Bedrock Agent - Getting Data Source...")
|
||||
try:
|
||||
# GetDataSource nests everything under a top-level dataSource object.
|
||||
data_source_info = (
|
||||
self.regional_clients[data_source.region]
|
||||
.get_data_source(
|
||||
knowledgeBaseId=data_source.knowledge_base_id,
|
||||
dataSourceId=data_source.id,
|
||||
)
|
||||
.get("dataSource", {})
|
||||
)
|
||||
data_source.kms_key_arn = data_source_info.get(
|
||||
"serverSideEncryptionConfiguration", {}
|
||||
).get("kmsKeyArn")
|
||||
data_source.detail_retrieved = True
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{data_source.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_tags_for_resource(self, resource):
|
||||
"""List tags for a Bedrock Agent resource."""
|
||||
logger.info("Bedrock Agent - Listing Tags for Resource...")
|
||||
@@ -568,17 +256,6 @@ class Agent(BaseModel):
|
||||
role_arn: Optional[str] = None
|
||||
region: str
|
||||
tags: Optional[list] = []
|
||||
# False when GetAgent failed: absent role is unknown, not unset.
|
||||
detail_retrieved: bool = False
|
||||
# Execution role of each numbered version an alias routes to, keyed by
|
||||
# version. A version is an immutable snapshot, so it keeps the role it was
|
||||
# cut with even after the working draft's role changes.
|
||||
version_role_arns: dict = {}
|
||||
# True once the alias and version inventory was read in full, so an agent
|
||||
# with no deployed versions genuinely has none rather than that they could
|
||||
# not be listed.
|
||||
versions_listed: bool = False
|
||||
versions_error: Optional[str] = None
|
||||
|
||||
|
||||
class Prompt(BaseModel):
|
||||
@@ -589,30 +266,3 @@ class Prompt(BaseModel):
|
||||
arn: str
|
||||
region: str
|
||||
customer_encryption_key_arn: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeBase(BaseModel):
|
||||
"""Model representing a Bedrock Agent knowledge base."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
# False when ListDataSources failed: empty set is unknown, not none.
|
||||
data_sources_listed: bool = False
|
||||
# The error code from a failed ListDataSources, for the finding message.
|
||||
data_sources_error: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeBaseDataSource(BaseModel):
|
||||
"""Model representing a data source attached to a Bedrock knowledge base."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
knowledge_base_id: str
|
||||
knowledge_base_name: Optional[str] = None
|
||||
kms_key_arn: Optional[str] = None
|
||||
# False when GetDataSource failed: absent key is unknown, not unset.
|
||||
detail_retrieved: bool = False
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "ecr_repository_image_no_secrets",
|
||||
"CheckTitle": "ECR repository image contains no hardcoded secrets",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Sensitive Data Identifications/Passwords",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "ecr",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsEcrRepository",
|
||||
"ResourceGroup": "container",
|
||||
"Description": "The **latest image** pushed to each **Amazon ECR repository** is analyzed for **embedded secrets**: environment variables and build history (Dockerfile instructions) recorded in the image configuration, plus the file contents of every filesystem layer. Findings reference the variable, build step, or file, never the secret value.",
|
||||
"Risk": "Anyone able to pull the image obtains any **credentials, tokens, or keys** embedded at build time via `ENV`, `ARG`, inline `RUN` commands, or files copied into the image (e.g. `COPY .env .`).\n\nLeaked credentials enable unauthorized access to databases, APIs, or cloud resources, and rotation is harder once secrets are baked into distributed image artifacts.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AmazonECR/latest/userguide/security-best-practices.html",
|
||||
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html",
|
||||
"https://docs.docker.com/build/building/secrets/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws ecr batch-delete-image --repository-name <repository-name> --image-ids imageDigest=<image-digest>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Remove the secret from the Dockerfile (ENV/ARG/RUN) or from any file copied into the build context, and rebuild the image without it.\n2. Provide the secret at runtime instead: reference AWS Secrets Manager or SSM Parameter Store from your ECS task definition, EKS pod (Secrets Store CSI driver), or application code.\n3. Push the rebuilt image and delete the compromised image versions from the repository.\n4. Rotate the exposed credential immediately.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Never bake secrets into images with `ENV`, `ARG`, inline `RUN` commands, or copied files. Use **BuildKit build secrets** (`--mount=type=secret`) at build time and **AWS Secrets Manager**/Parameter Store at runtime. Add secret scanning to CI/CD before pushing images.",
|
||||
"Url": "https://hub.prowler.com/check/ecr_repository_image_no_secrets"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Only the most recently pushed image in each repository is scanned; older tagged images are not scanned. The latest scannable image is evaluated in every repository regardless of whether scan-on-push is enabled. The scanned image's configuration (environment variables and build history) plus every filesystem layer's file contents are analyzed. A multi-architecture image resolves to a single platform's manifest; other architectures in the same manifest list are not scanned. To bound cost, a single layer over 100 MB (compressed) is not downloaded, an individual file over 1 MB is not scanned, and scanning of an image stops after 5000 files or 500 MB (decompressed). When part of an image cannot be scanned this way, a clean result is reported as MANUAL (coverage was incomplete) rather than PASS, and a FAIL still discloses that some content was skipped. Requires the ecr:BatchGetImage and ecr:GetDownloadUrlForLayer permissions in addition to SecurityAudit."
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import re
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.lib.utils.utils import (
|
||||
SecretsScanError,
|
||||
annotate_verified_secrets,
|
||||
detect_secrets_scan_batch,
|
||||
)
|
||||
from prowler.providers.aws.services.ecr.ecr_client import ecr_client
|
||||
|
||||
_SAFE_ENVIRONMENT_VARIABLE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
||||
|
||||
|
||||
class ecr_repository_image_no_secrets(Check):
|
||||
"""Ensure the latest ECR repository image embeds no hardcoded secrets.
|
||||
|
||||
The most recently pushed image in every ECR repository is resolved to a
|
||||
single scannable manifest (a multi-arch image resolves to one platform's
|
||||
manifest; other architectures in the same manifest list are not
|
||||
scanned) and scanned for plaintext secrets in its configuration
|
||||
(environment variables, build history) and every filesystem layer's
|
||||
file contents. Older tagged images are not scanned.
|
||||
- PASS: no secrets detected and the whole image was scanned.
|
||||
- FAIL: a potential secret was detected; the variable, build step, or
|
||||
file is reported, never the secret value.
|
||||
- MANUAL: the image could not be scanned in full, so a clean result would
|
||||
be misleading -- the manifest could not be retrieved or resolved, the
|
||||
scan itself failed, or part of the image exceeded configured size limits
|
||||
or could not be retrieved.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
secrets_ignore_patterns = ecr_client.audit_config.get(
|
||||
"secrets_ignore_patterns", []
|
||||
)
|
||||
validate = ecr_client.audit_config.get("secrets_validate", False)
|
||||
|
||||
# Phase 1: collect. The service yields (repository, image, scan_data)
|
||||
# lazily, downloading each image's manifest, config, and layers; each
|
||||
# image contributes an env/history/file payload per scannable unit so
|
||||
# a finding's key maps back to a variable, build step, or file.
|
||||
scanned = []
|
||||
|
||||
def image_payloads():
|
||||
"""Yield keyed scan payloads, recording each image into `scanned`."""
|
||||
for repository, image, scan_data in ecr_client._get_image_scan_data():
|
||||
index = len(scanned)
|
||||
scanned.append((repository, image, scan_data))
|
||||
if scan_data is None or isinstance(scan_data, Exception):
|
||||
continue
|
||||
for env_index, entry in enumerate(scan_data.env):
|
||||
yield (index, f"environment:{env_index}"), entry
|
||||
for history_index, entry in enumerate(scan_data.history):
|
||||
yield (index, f"history:{history_index}"), entry
|
||||
for file_index, scanned_file in enumerate(scan_data.files):
|
||||
yield (index, f"file:{file_index}"), scanned_file.content
|
||||
# Free the file's contents once handed to the scanner. The
|
||||
# report phase needs only its path and layer digest, so
|
||||
# retained memory stays flat instead of growing with the
|
||||
# number of repositories scanned.
|
||||
scanned_file.content = ""
|
||||
|
||||
# Phase 2: batch — one call, chunked Kingfisher subprocesses. This
|
||||
# must fully consume image_payloads() so every image is appended to
|
||||
# `scanned` before Phase 3 runs; detect_secrets_scan_batch does so
|
||||
# today, but a future short-circuit there would silently drop images
|
||||
# from the report loop.
|
||||
scan_error = None
|
||||
try:
|
||||
batch_results = detect_secrets_scan_batch(
|
||||
image_payloads(),
|
||||
excluded_secrets=secrets_ignore_patterns,
|
||||
validate=validate,
|
||||
)
|
||||
except SecretsScanError as error:
|
||||
batch_results = {}
|
||||
scan_error = error
|
||||
|
||||
if scan_error:
|
||||
# The scan failed and the payload generator may not have been
|
||||
# consumed, so build the MANUAL reports from the repositories
|
||||
# themselves rather than risk a false PASS or a missing finding.
|
||||
for registry in ecr_client.registries.values():
|
||||
for repository in registry.repositories:
|
||||
image = ecr_client._get_scan_target_image(repository)
|
||||
if isinstance(image, Exception):
|
||||
findings.append(
|
||||
self._build_scan_error_report(repository, image)
|
||||
)
|
||||
elif image is not None:
|
||||
report = self._build_report(repository, image)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not scan image '{image.latest_tag}' "
|
||||
f"({image.latest_digest}) of ECR repository "
|
||||
f"{repository.name} for secrets: {scan_error}; "
|
||||
f"manual review is required."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
# Phase 3: report — one finding per scanned image.
|
||||
for index, (repository, image, scan_data) in enumerate(scanned):
|
||||
if isinstance(scan_data, Exception):
|
||||
findings.append(self._build_scan_error_report(repository, scan_data))
|
||||
continue
|
||||
report = self._build_report(repository, image)
|
||||
image_reference = (
|
||||
f"image '{image.latest_tag}' ({image.latest_digest}) of ECR "
|
||||
f"repository {repository.name}"
|
||||
)
|
||||
|
||||
if scan_data is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not resolve or retrieve the manifest of the "
|
||||
f"{image_reference} to scan it for secrets; manual "
|
||||
f"review is required."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
env_findings_by_index = {
|
||||
int(key[1].split(":", 1)[1]): entry_secrets
|
||||
for key, entry_secrets in batch_results.items()
|
||||
if key[0] == index and key[1].startswith("environment:")
|
||||
}
|
||||
history_findings_by_index = {
|
||||
int(key[1].split(":", 1)[1]): entry_secrets
|
||||
for key, entry_secrets in batch_results.items()
|
||||
if key[0] == index and key[1].startswith("history:")
|
||||
}
|
||||
file_findings_by_index = {
|
||||
int(key[1].split(":", 1)[1]): file_secrets
|
||||
for key, file_secrets in batch_results.items()
|
||||
if key[0] == index and key[1].startswith("file:")
|
||||
}
|
||||
|
||||
if (
|
||||
env_findings_by_index
|
||||
or history_findings_by_index
|
||||
or file_findings_by_index
|
||||
):
|
||||
secrets_found = []
|
||||
all_secrets = []
|
||||
|
||||
for env_index, env_findings in env_findings_by_index.items():
|
||||
variable = None
|
||||
if 0 <= env_index < len(scan_data.env):
|
||||
entry = scan_data.env[env_index]
|
||||
# Only a well-formed "NAME=value" entry has a name safe
|
||||
# to report; an entry with no "=" may itself be the
|
||||
# secret, so it is never echoed back.
|
||||
if "=" in entry:
|
||||
candidate = entry.split("=", 1)[0]
|
||||
if _SAFE_ENVIRONMENT_VARIABLE_NAME.fullmatch(candidate):
|
||||
variable = candidate
|
||||
all_secrets.extend(env_findings)
|
||||
for secret in env_findings:
|
||||
if variable is not None:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in environment variable {variable}"
|
||||
)
|
||||
else:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in image environment variables"
|
||||
)
|
||||
for (
|
||||
history_index,
|
||||
history_findings,
|
||||
) in history_findings_by_index.items():
|
||||
all_secrets.extend(history_findings)
|
||||
for secret in history_findings:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in image history step {history_index + 1}"
|
||||
)
|
||||
for file_index, file_secrets in file_findings_by_index.items():
|
||||
scanned_file = scan_data.files[file_index]
|
||||
all_secrets.extend(file_secrets)
|
||||
for secret in file_secrets:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in file {scanned_file.path} "
|
||||
f"(layer {scanned_file.layer_digest})"
|
||||
)
|
||||
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Potential {'secrets' if len(secrets_found) > 1 else 'secret'} "
|
||||
f"found in the {image_reference} -> {', '.join(secrets_found)}."
|
||||
)
|
||||
if scan_data.truncated:
|
||||
report.status_extended += (
|
||||
" Some of the image could not be retrieved or exceeded "
|
||||
"configured size limits and was not scanned."
|
||||
)
|
||||
annotate_verified_secrets(report, all_secrets)
|
||||
elif scan_data.truncated:
|
||||
# No secrets in what was scanned, but coverage was incomplete
|
||||
# (size/count limits, or the config could not be retrieved), so
|
||||
# a clean result would be misleading.
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"No secrets were found in the scanned portion of the "
|
||||
f"{image_reference}, but part of it could not be retrieved "
|
||||
f"or exceeded configured size limits and was not scanned; "
|
||||
f"manual review is required."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"No secrets found in the {image_reference}."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
def _build_scan_error_report(self, repository, error) -> Check_Report_AWS:
|
||||
"""Build a repository-level report for a latest-image lookup failure."""
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=repository)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not determine the latest image of ECR repository "
|
||||
f"{repository.name}: {error}; manual review is required."
|
||||
)
|
||||
return report
|
||||
|
||||
def _build_report(self, repository, image) -> Check_Report_AWS:
|
||||
"""Build a report scoped to a single image within a repository.
|
||||
|
||||
ECR images have no ARN of their own, so the repository's ARN is
|
||||
reused with the image digest appended as a synthetic suffix,
|
||||
mirroring how other sub-resource checks (e.g. CodeArtifact packages
|
||||
within a repository) identify per-item findings.
|
||||
"""
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=repository)
|
||||
digest_short = image.latest_digest.split(":")[-1][:12]
|
||||
report.resource_id = f"{repository.name}:{image.latest_tag}@{digest_short}"
|
||||
report.resource_arn = f"{repository.arn}/image/{digest_short}"
|
||||
return report
|
||||
@@ -1,4 +1,3 @@
|
||||
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
||||
from datetime import datetime
|
||||
from json import loads
|
||||
from typing import Optional
|
||||
@@ -9,21 +8,10 @@ from pydantic.v1 import BaseModel
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
|
||||
from prowler.providers.aws.lib.service.service import AWSService
|
||||
from prowler.providers.aws.services.ecr.image_inspection import ImageInspector
|
||||
|
||||
# Concurrency for the image-scan pipeline (_get_image_scan_data). Kept smaller
|
||||
# than the shared MAX_WORKERS metadata pool because each task can retain up to
|
||||
# MAX_LAYER_DOWNLOAD_BYTES compressed plus MAX_TOTAL_BYTES_PER_IMAGE decompressed
|
||||
# content (see image_inspection), so a high worker count would multiply peak
|
||||
# memory into several GB.
|
||||
IMAGE_SCAN_MAX_WORKERS = 4
|
||||
|
||||
|
||||
class ECR(AWSService):
|
||||
"""AWS Elastic Container Registry service."""
|
||||
|
||||
def __init__(self, provider):
|
||||
"""Discover registries, repositories, policies, and image metadata."""
|
||||
# Call AWSService's __init__
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.registry_id = self.audited_account
|
||||
@@ -36,7 +24,6 @@ class ECR(AWSService):
|
||||
self.__threading_call__(self._list_tags_for_resource)
|
||||
|
||||
def _describe_registries_and_repositories(self, regional_client):
|
||||
"""Populate the registry and its repositories for one region."""
|
||||
logger.info("ECR - Describing registries and repositories...")
|
||||
regional_registry_repositories = []
|
||||
try:
|
||||
@@ -81,7 +68,6 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _describe_repository_policies(self, regional_client):
|
||||
"""Fetch and attach each repository's resource policy, if any."""
|
||||
logger.info("ECR - Describing repository policies...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -110,7 +96,6 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _get_repository_lifecycle_policy(self, regional_client):
|
||||
"""Fetch and attach each repository's lifecycle policy, if any."""
|
||||
logger.info("ECR - Getting repository lifecycle policy...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -139,7 +124,6 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _get_image_details(self, regional_client):
|
||||
"""Populate each scan-on-push repository's scannable, tagged images."""
|
||||
logger.info("ECR - Getting images details...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -174,7 +158,12 @@ class ECR(AWSService):
|
||||
image_scan_findings_field_name = (
|
||||
"imageScanFindingsSummary"
|
||||
)
|
||||
type = ECR._artifact_type(artifact_media_type)
|
||||
if "docker" in artifact_media_type:
|
||||
type = "Docker"
|
||||
elif "oci" in artifact_media_type:
|
||||
type = "OCI"
|
||||
else:
|
||||
type = ""
|
||||
|
||||
# If imageScanStatus is not present or imageScanFindingsSummary is missing,
|
||||
# we need to call DescribeImageScanFindings because AWS' new version of
|
||||
@@ -263,7 +252,6 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _list_tags_for_resource(self, regional_client):
|
||||
"""Fetch and attach each repository's resource tags."""
|
||||
logger.info("ECR - List Tags...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -292,7 +280,6 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _get_registry_scanning_configuration(self, regional_client):
|
||||
"""Fetch and attach the registry's image-scanning configuration."""
|
||||
logger.info("ECR - Getting Registry Scanning Configuration...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -328,155 +315,6 @@ class ECR(AWSService):
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_image_scan_data(self):
|
||||
"""Lazily fetch manifest, config, and layer file contents for the latest image.
|
||||
|
||||
Only the most recently pushed scannable image in each repository is
|
||||
scanned (resolved via _get_scan_target_image, which also covers
|
||||
scan-on-push-disabled repositories) to bound cost on repositories
|
||||
with many tags.
|
||||
|
||||
Not called from __init__: this is only invoked by the
|
||||
ecr_repository_image_no_secrets check, since it downloads and
|
||||
decompresses image layers and is significantly more expensive than
|
||||
the metadata gathered above. A dedicated, smaller thread pool bounds
|
||||
the concurrency (and therefore the peak memory) of this heavy
|
||||
pipeline independently of the shared metadata pool.
|
||||
|
||||
Yields:
|
||||
Tuple of repository, optional image, and scan data. The third item
|
||||
is an exception when the authoritative image lookup failed.
|
||||
"""
|
||||
logger.info("ECR - Fetching image manifests, configs, and layers...")
|
||||
inspector = ImageInspector()
|
||||
|
||||
def images_to_fetch():
|
||||
for registry in self.registries.values():
|
||||
for repository in registry.repositories:
|
||||
image = self._get_scan_target_image(repository)
|
||||
if isinstance(image, Exception):
|
||||
yield repository, None, image
|
||||
elif image is not None:
|
||||
yield repository, image, None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=IMAGE_SCAN_MAX_WORKERS) as executor:
|
||||
pending = {}
|
||||
targets = iter(images_to_fetch())
|
||||
|
||||
def submit_next():
|
||||
try:
|
||||
repository, image, error = next(targets)
|
||||
except StopIteration:
|
||||
return False
|
||||
if error:
|
||||
future = Future()
|
||||
future.set_result(error)
|
||||
else:
|
||||
client = self.regional_clients[repository.region]
|
||||
registry_id = self.registries[repository.region].id
|
||||
future = executor.submit(
|
||||
inspector.fetch_image_scan_data,
|
||||
client,
|
||||
registry_id,
|
||||
repository.name,
|
||||
image.latest_digest,
|
||||
)
|
||||
pending[future] = (repository, image)
|
||||
return True
|
||||
|
||||
for _ in range(IMAGE_SCAN_MAX_WORKERS):
|
||||
if not submit_next():
|
||||
break
|
||||
|
||||
while pending:
|
||||
completed, _ = wait(pending, return_when=FIRST_COMPLETED)
|
||||
for future in completed:
|
||||
repository, image = pending.pop(future)
|
||||
scan_data = None
|
||||
try:
|
||||
scan_data = future.result()
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
yield repository, image, scan_data
|
||||
submit_next()
|
||||
|
||||
@staticmethod
|
||||
def _artifact_type(artifact_media_type: Optional[str]) -> str:
|
||||
"""Map an image's artifact media type to a short image type label.
|
||||
|
||||
Returns:
|
||||
"Docker", "OCI", or "" for an unrecognized/absent media type.
|
||||
"""
|
||||
if artifact_media_type:
|
||||
if "docker" in artifact_media_type:
|
||||
return "Docker"
|
||||
if "oci" in artifact_media_type:
|
||||
return "OCI"
|
||||
return ""
|
||||
|
||||
def _get_scan_target_image(self, repository) -> Optional["ImageDetails"]:
|
||||
"""Resolve the latest scannable image to scan for secrets.
|
||||
|
||||
Secret scanning is independent of ECR's vulnerability scanning
|
||||
configuration, but `_get_image_details` only populates
|
||||
`images_details` for scan-on-push-enabled repositories. For a
|
||||
repository with scan-on-push disabled (empty `images_details`), this
|
||||
performs a dedicated `describe_images` lookup to find the most
|
||||
recently pushed scannable image, so those repositories are not
|
||||
silently skipped.
|
||||
|
||||
The synthesized ImageDetails is deliberately NOT appended to
|
||||
`repository.images_details`: other checks (e.g.
|
||||
ecr_repositories_scan_vulnerabilities_in_latest_image) treat any
|
||||
entry there as a scanned image and would FAIL scan-on-push-disabled
|
||||
repositories that currently produce no finding.
|
||||
|
||||
Returns:
|
||||
The latest scannable ImageDetails, or None if the repository has
|
||||
no scannable image; an exception if the lookup failed.
|
||||
"""
|
||||
latest = repository.images_details[-1] if repository.images_details else None
|
||||
try:
|
||||
client = self.regional_clients[repository.region]
|
||||
describe_images_paginator = client.get_paginator("describe_images")
|
||||
for page in describe_images_paginator.paginate(
|
||||
registryId=self.registries[repository.region].id,
|
||||
repositoryName=repository.name,
|
||||
PaginationConfig={"PageSize": 1000},
|
||||
):
|
||||
for image in page["imageDetails"]:
|
||||
if image is None:
|
||||
continue
|
||||
artifact_media_type = image.get("artifactMediaType", None)
|
||||
tags = image.get("imageTags", [])
|
||||
if not ECR._is_artifact_scannable(artifact_media_type, tags):
|
||||
continue
|
||||
image_pushed_at = image.get("imagePushedAt")
|
||||
if image_pushed_at is None:
|
||||
continue
|
||||
# Match _get_image_details' "sort ascending, take last"
|
||||
# selection: on equal push dates the later-listed image
|
||||
# wins, so `<` (not `<=`) is used to replace on ties.
|
||||
if latest is not None and image_pushed_at < latest.image_pushed_at:
|
||||
continue
|
||||
latest = ImageDetails(
|
||||
latest_tag=image.get("imageTags", ["None"])[0],
|
||||
image_pushed_at=image_pushed_at,
|
||||
latest_digest=image.get("imageDigest"),
|
||||
scan_findings_status=None,
|
||||
scan_findings_severity_count=None,
|
||||
artifact_media_type=artifact_media_type,
|
||||
type=ECR._artifact_type(artifact_media_type),
|
||||
)
|
||||
return latest
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return error
|
||||
|
||||
@staticmethod
|
||||
def _is_artifact_scannable(artifact_media_type: str, tags: list[str] = []) -> bool:
|
||||
"""
|
||||
@@ -517,16 +355,12 @@ class ECR(AWSService):
|
||||
|
||||
|
||||
class FindingSeverityCounts(BaseModel):
|
||||
"""Count of an image's vulnerability scan findings by severity."""
|
||||
|
||||
critical: int
|
||||
high: int
|
||||
medium: int
|
||||
|
||||
|
||||
class ImageDetails(BaseModel):
|
||||
"""A single scannable, tagged image within an ECR repository."""
|
||||
|
||||
latest_tag: str
|
||||
latest_digest: str
|
||||
image_pushed_at: datetime
|
||||
@@ -537,8 +371,6 @@ class ImageDetails(BaseModel):
|
||||
|
||||
|
||||
class Repository(BaseModel):
|
||||
"""An ECR repository and its policies, images, and tags."""
|
||||
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
@@ -552,15 +384,11 @@ class Repository(BaseModel):
|
||||
|
||||
|
||||
class ScanningRule(BaseModel):
|
||||
"""A registry-level image-scanning rule and its repository filters."""
|
||||
|
||||
scan_frequency: str
|
||||
scan_filters: list[dict]
|
||||
|
||||
|
||||
class Registry(BaseModel):
|
||||
"""An ECR registry: its repositories and scanning configuration."""
|
||||
|
||||
id: str
|
||||
arn: str
|
||||
region: str
|
||||
|
||||
@@ -1,494 +0,0 @@
|
||||
import gzip
|
||||
import tarfile
|
||||
from contextlib import contextmanager
|
||||
from json import loads
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
import zstandard
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
# Manifest media types that wrap several per-architecture manifests (a "fat
|
||||
# manifest") rather than a single scannable image.
|
||||
_MANIFEST_LIST_MEDIA_TYPES = {
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
}
|
||||
|
||||
# Compressed size of a single layer, checked against the manifest-declared
|
||||
# size before downloading, and re-checked against actual bytes received.
|
||||
MAX_LAYER_DOWNLOAD_BYTES = 100 * 1024 * 1024
|
||||
# Size of a single extracted file considered for scanning.
|
||||
MAX_FILE_BYTES = 1 * 1024 * 1024
|
||||
# Hard cap on the number of files scanned per image, across all its layers.
|
||||
MAX_FILES_PER_IMAGE = 5000
|
||||
# Hard cap on total decompressed bytes read per image, across all its layers.
|
||||
MAX_TOTAL_BYTES_PER_IMAGE = 500 * 1024 * 1024
|
||||
LAYER_DOWNLOAD_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class _LayerTooLargeError(Exception):
|
||||
"""Raised when a streamed layer exceeds MAX_LAYER_DOWNLOAD_BYTES."""
|
||||
|
||||
|
||||
class _ImageTooLargeError(Exception):
|
||||
"""Raised when decompressed image streams exceed their shared budget."""
|
||||
|
||||
|
||||
class _CappedLayerReader:
|
||||
"""A minimal read-only file object that caps the bytes it will yield.
|
||||
|
||||
Wraps a streaming HTTP body (urllib3's ``response.raw``) so ``tarfile`` can
|
||||
read a gzip/uncompressed layer incrementally while enforcing an upper bound
|
||||
on the compressed bytes consumed. A manifest that under-declares a layer's
|
||||
size (the declared size is pre-checked separately) cannot make this buffer
|
||||
an unbounded amount of untrusted data: once ``max_bytes`` is exceeded the
|
||||
read raises ``_LayerTooLargeError`` instead of continuing.
|
||||
"""
|
||||
|
||||
def __init__(self, raw, max_bytes: int):
|
||||
"""Store the underlying raw stream and the remaining byte budget."""
|
||||
self._raw = raw
|
||||
self._remaining = max_bytes
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
"""Read up to ``size`` bytes, never exceeding the remaining budget.
|
||||
|
||||
A negative/None ``size`` (``read all``) is treated as "read what's left
|
||||
of the budget, plus one" so a lying stream can never pull an unbounded
|
||||
amount into memory and an over-cap layer is still detected.
|
||||
"""
|
||||
if size is None or size < 0:
|
||||
size = self._remaining + 1
|
||||
to_read = min(size, self._remaining + 1)
|
||||
chunk = self._raw.read(to_read)
|
||||
self._remaining -= len(chunk)
|
||||
if self._remaining < 0:
|
||||
raise _LayerTooLargeError()
|
||||
return chunk
|
||||
|
||||
|
||||
class _DecompressedByteBudget:
|
||||
"""Track every decompressed byte consumed across an image's tar streams."""
|
||||
|
||||
def __init__(self, max_bytes: int):
|
||||
"""Set the shared decompressed-byte allowance."""
|
||||
self.remaining = max_bytes
|
||||
|
||||
def wrap(self, raw):
|
||||
"""Return a reader that charges bytes consumed from ``raw``."""
|
||||
return _BudgetedReader(raw, self)
|
||||
|
||||
|
||||
class _BudgetedReader:
|
||||
"""Charge all stream reads against a shared decompressed-byte budget."""
|
||||
|
||||
def __init__(self, raw, budget: _DecompressedByteBudget):
|
||||
self._raw = raw
|
||||
self._budget = budget
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
"""Read without allowing the shared budget to be exceeded."""
|
||||
if size is None or size < 0:
|
||||
size = self._budget.remaining + 1
|
||||
chunk = self._raw.read(min(size, self._budget.remaining + 1))
|
||||
self._budget.remaining -= len(chunk)
|
||||
if self._budget.remaining < 0:
|
||||
raise _ImageTooLargeError()
|
||||
return chunk
|
||||
|
||||
|
||||
class ImageScanFile(BaseModel):
|
||||
"""A single file extracted from an image layer for secret scanning."""
|
||||
|
||||
path: str
|
||||
layer_digest: str
|
||||
content: str
|
||||
|
||||
|
||||
class ImageScanData(BaseModel):
|
||||
"""An image's scannable content: config env/history and layer files."""
|
||||
|
||||
env: list[str] = []
|
||||
history: list[str] = []
|
||||
files: list[ImageScanFile] = []
|
||||
# True when part of the image was not scanned -- a layer/file exceeded a
|
||||
# configured size or count limit, or the config blob could not be
|
||||
# retrieved/parsed -- so a clean result can be reported as MANUAL
|
||||
# (coverage incomplete) rather than a false PASS.
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class ImageInspector:
|
||||
"""Bounded, opt-in extraction of an ECR image's scannable content.
|
||||
|
||||
Given a boto3 ECR client and an image digest, resolves the image's
|
||||
manifest (handling multi-arch manifest lists and skipping attestation
|
||||
manifests) and returns its configuration (environment variables, build
|
||||
history) and every filesystem layer's file contents, subject to this
|
||||
module's size and count limits.
|
||||
|
||||
This is deliberately isolated from the ECR service so a future check can
|
||||
reuse the bounded extraction without the service downloading and
|
||||
decompressing image layers by default: the service only pays this cost
|
||||
when a check explicitly drives the inspector.
|
||||
"""
|
||||
|
||||
def fetch_image_scan_data(
|
||||
self, client, registry_id, repository_name, image_digest
|
||||
) -> Optional[ImageScanData]:
|
||||
"""Resolve one image's manifest and return its scannable content.
|
||||
|
||||
Downloads the config blob (environment variables, build history)
|
||||
and every filesystem layer's file contents, bounded by the module's
|
||||
size/count limits.
|
||||
|
||||
Returns:
|
||||
An ImageScanData, or None if the manifest could not be resolved.
|
||||
"""
|
||||
manifest, truncated = self._resolve_image_manifest(
|
||||
client, registry_id, repository_name, image_digest
|
||||
)
|
||||
if manifest is None:
|
||||
return None
|
||||
|
||||
env = []
|
||||
history = []
|
||||
|
||||
config_digest = (manifest.get("config") or {}).get("digest")
|
||||
if config_digest:
|
||||
config_bytes = self._download_layer(
|
||||
client,
|
||||
registry_id,
|
||||
repository_name,
|
||||
config_digest,
|
||||
max_bytes=MAX_FILE_BYTES,
|
||||
)
|
||||
if config_bytes is None:
|
||||
# The config blob (env vars, build history) could not be
|
||||
# retrieved. Empty env/history would be indistinguishable
|
||||
# from a clean config, so mark coverage incomplete instead
|
||||
# of risking a false PASS.
|
||||
truncated = True
|
||||
else:
|
||||
try:
|
||||
config_json = loads(config_bytes)
|
||||
env = config_json.get("config", {}).get("Env", []) or []
|
||||
history = [
|
||||
step.get("created_by", "")
|
||||
for step in config_json.get("history", [])
|
||||
if step.get("created_by")
|
||||
]
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
truncated = True
|
||||
|
||||
files = []
|
||||
decompressed_budget = _DecompressedByteBudget(MAX_TOTAL_BYTES_PER_IMAGE)
|
||||
for layer in manifest.get("layers", []):
|
||||
if len(files) >= MAX_FILES_PER_IMAGE or decompressed_budget.remaining <= 0:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
layer_digest = layer.get("digest")
|
||||
layer_size = layer.get("size", 0)
|
||||
if layer_size and layer_size > MAX_LAYER_DOWNLOAD_BYTES:
|
||||
truncated = True
|
||||
continue
|
||||
|
||||
try:
|
||||
with self._open_layer_tar_stream(
|
||||
client,
|
||||
registry_id,
|
||||
repository_name,
|
||||
layer_digest,
|
||||
layer.get("mediaType", ""),
|
||||
decompressed_budget,
|
||||
) as tar_stream:
|
||||
if tar_stream is None:
|
||||
truncated = True
|
||||
continue
|
||||
for member in tar_stream:
|
||||
if len(files) >= MAX_FILES_PER_IMAGE:
|
||||
truncated = True
|
||||
break
|
||||
if not member.isfile():
|
||||
continue
|
||||
base_name = member.name.rsplit("/", 1)[-1]
|
||||
if base_name.startswith(".wh."):
|
||||
# Whiteout marker: a deletion recorded by the union
|
||||
# filesystem, not real file content.
|
||||
continue
|
||||
if member.size > MAX_FILE_BYTES:
|
||||
truncated = True
|
||||
continue
|
||||
try:
|
||||
content = (
|
||||
tar_stream.extractfile(member).read().decode("latin-1")
|
||||
)
|
||||
except _LayerTooLargeError:
|
||||
# Over-cap while reading this member: truncate the
|
||||
# whole layer rather than silently skipping one file.
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
files.append(
|
||||
ImageScanFile(
|
||||
path=member.name,
|
||||
layer_digest=layer_digest,
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
except _LayerTooLargeError:
|
||||
# The layer streamed more bytes than MAX_LAYER_DOWNLOAD_BYTES
|
||||
# (a manifest under-declaring its size); skip it and disclose
|
||||
# the partial coverage rather than buffer unbounded data.
|
||||
truncated = True
|
||||
continue
|
||||
except _ImageTooLargeError:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
return ImageScanData(env=env, history=history, files=files, truncated=truncated)
|
||||
|
||||
def _resolve_image_manifest(
|
||||
self, client, registry_id, repository_name, image_digest
|
||||
) -> tuple[Optional[dict], bool]:
|
||||
"""Resolve an image digest to a single scannable image manifest.
|
||||
|
||||
Multi-arch images are stored as a manifest list/image index pointing
|
||||
at one manifest per platform (plus, often, an attestation manifest
|
||||
that isn't a real image). This picks one real platform manifest to
|
||||
scan; the other architectures in the same list are not scanned.
|
||||
"""
|
||||
try:
|
||||
manifest, media_type = self._batch_get_manifest(
|
||||
client, registry_id, repository_name, image_digest
|
||||
)
|
||||
if manifest is None:
|
||||
return None, False
|
||||
|
||||
truncated = False
|
||||
if media_type in _MANIFEST_LIST_MEDIA_TYPES:
|
||||
truncated = True
|
||||
child_digest = self._select_child_manifest_digest(manifest)
|
||||
if not child_digest:
|
||||
return None, truncated
|
||||
manifest, _ = self._batch_get_manifest(
|
||||
client, registry_id, repository_name, child_digest
|
||||
)
|
||||
if manifest is not None and not (
|
||||
manifest.get("config") or manifest.get("layers")
|
||||
):
|
||||
# A resolved manifest with neither a config nor layers has
|
||||
# nothing to scan (e.g. a nested manifest list, or an
|
||||
# unsupported manifest shape) -- treat it as unresolvable so
|
||||
# the caller reports MANUAL instead of a false PASS.
|
||||
return None, truncated
|
||||
return manifest, truncated
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{client.meta.region_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
@staticmethod
|
||||
def _batch_get_manifest(client, registry_id, repository_name, image_digest):
|
||||
"""Fetch and parse the raw manifest JSON for a single image digest.
|
||||
|
||||
Returns:
|
||||
A (manifest, media_type) tuple, or (None, None) if not found.
|
||||
"""
|
||||
response = client.batch_get_image(
|
||||
registryId=registry_id,
|
||||
repositoryName=repository_name,
|
||||
imageIds=[{"imageDigest": image_digest}],
|
||||
)
|
||||
images = response.get("images", [])
|
||||
if not images:
|
||||
return None, None
|
||||
manifest = loads(images[0]["imageManifest"])
|
||||
media_type = manifest.get("mediaType") or images[0].get(
|
||||
"imageManifestMediaType"
|
||||
)
|
||||
return manifest, media_type
|
||||
|
||||
@staticmethod
|
||||
def _select_child_manifest_digest(manifest_list: dict) -> Optional[str]:
|
||||
"""Pick one real platform manifest's digest from a manifest list.
|
||||
|
||||
Prefers linux/amd64, falling back to the first remaining candidate
|
||||
once attestation manifests (platform "unknown/unknown", or
|
||||
annotated as an attestation manifest) are excluded.
|
||||
|
||||
Returns:
|
||||
The chosen manifest's digest, or None if no candidate remains.
|
||||
"""
|
||||
candidates = []
|
||||
for entry in manifest_list.get("manifests", []):
|
||||
platform = entry.get("platform", {}) or {}
|
||||
annotations = entry.get("annotations", {}) or {}
|
||||
if (
|
||||
platform.get("architecture") == "unknown"
|
||||
or platform.get("os") == "unknown"
|
||||
):
|
||||
# Attestation manifests (SBOMs, provenance, signatures) are
|
||||
# attached to the index as "unknown/unknown" platform entries.
|
||||
continue
|
||||
if annotations.get("vnd.docker.reference.type") == "attestation-manifest":
|
||||
continue
|
||||
candidates.append(entry)
|
||||
|
||||
for entry in candidates:
|
||||
platform = entry.get("platform", {}) or {}
|
||||
if (
|
||||
platform.get("architecture") == "amd64"
|
||||
and platform.get("os") == "linux"
|
||||
):
|
||||
return entry.get("digest")
|
||||
|
||||
return candidates[0].get("digest") if candidates else None
|
||||
|
||||
@staticmethod
|
||||
def _get_layer_download_url(
|
||||
client, registry_id, repository_name, layer_digest
|
||||
) -> Optional[str]:
|
||||
"""Resolve the presigned download URL for one layer or config blob.
|
||||
|
||||
Returns:
|
||||
The presigned URL, or None if ECR did not return one.
|
||||
"""
|
||||
response = client.get_download_url_for_layer(
|
||||
registryId=registry_id,
|
||||
repositoryName=repository_name,
|
||||
layerDigest=layer_digest,
|
||||
)
|
||||
return response.get("downloadUrl")
|
||||
|
||||
@staticmethod
|
||||
def _download_layer(
|
||||
client, registry_id, repository_name, layer_digest, max_bytes=None
|
||||
) -> Optional[bytes]:
|
||||
"""Download one layer or config blob via its presigned URL.
|
||||
|
||||
Streams the response, aborting once `max_bytes` is exceeded, so a
|
||||
lying or oversized blob is never buffered in full. Used for the config
|
||||
blob and for zstd layers (which cannot be streamed into tarfile);
|
||||
gzip/uncompressed layers are streamed by `_open_layer_tar_stream`.
|
||||
|
||||
Returns:
|
||||
The blob's bytes, or None if it could not be downloaded or
|
||||
exceeded `max_bytes`.
|
||||
"""
|
||||
try:
|
||||
download_url = ImageInspector._get_layer_download_url(
|
||||
client, registry_id, repository_name, layer_digest
|
||||
)
|
||||
if not download_url:
|
||||
return None
|
||||
|
||||
downloaded = bytearray()
|
||||
with requests.get(
|
||||
download_url,
|
||||
stream=True,
|
||||
timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
allow_redirects=False,
|
||||
) as http_response:
|
||||
http_response.raise_for_status()
|
||||
for chunk in http_response.iter_content(chunk_size=1024 * 1024):
|
||||
downloaded.extend(chunk)
|
||||
if max_bytes and len(downloaded) > max_bytes:
|
||||
return None
|
||||
return bytes(downloaded)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return None
|
||||
|
||||
@contextmanager
|
||||
def _open_layer_tar_stream(
|
||||
self,
|
||||
client,
|
||||
registry_id,
|
||||
repository_name,
|
||||
layer_digest,
|
||||
media_type: str,
|
||||
decompressed_budget: _DecompressedByteBudget,
|
||||
):
|
||||
"""Yield an open TarFile for one layer, streamed from the download.
|
||||
|
||||
gzip, zstd, and uncompressed tar layers are all streamed straight from
|
||||
the download into `tarfile` (streaming mode reads a file-like object
|
||||
sequentially), so neither the compressed blob nor a decompressed copy is
|
||||
ever buffered in full. A `_CappedLayerReader` enforces
|
||||
`MAX_LAYER_DOWNLOAD_BYTES` on the compressed bytes (guarding a manifest
|
||||
that under-declares the layer size); zstd is decompressed incrementally
|
||||
via `zstandard`'s streaming reader, so a crafted frame can no longer
|
||||
expand unbounded in memory, and the decompressed side is bounded by the
|
||||
caller's per-image budget as it iterates members.
|
||||
|
||||
Yields:
|
||||
An open TarFile, or None for an unrecognized media type or a
|
||||
download/decompression failure. Raises `_LayerTooLargeError` if a
|
||||
streamed layer's compressed bytes exceed `MAX_LAYER_DOWNLOAD_BYTES`.
|
||||
"""
|
||||
if media_type.endswith("gzip"):
|
||||
decompress = "gzip"
|
||||
elif media_type.endswith("zstd"):
|
||||
decompress = "zstd"
|
||||
elif media_type.endswith("tar"):
|
||||
decompress = None
|
||||
else:
|
||||
yield None
|
||||
return
|
||||
|
||||
# Only the setup (URL resolution, connection, tar-header parse) is
|
||||
# guarded here; a failure yields None. The `yield tar_stream` below is
|
||||
# kept out of this try so exceptions raised while the caller iterates
|
||||
# members (e.g. _LayerTooLargeError) propagate instead of triggering a
|
||||
# forbidden second yield.
|
||||
try:
|
||||
download_url = ImageInspector._get_layer_download_url(
|
||||
client, registry_id, repository_name, layer_digest
|
||||
)
|
||||
if not download_url:
|
||||
yield None
|
||||
return
|
||||
http_response = requests.get(
|
||||
download_url,
|
||||
stream=True,
|
||||
timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
allow_redirects=False,
|
||||
)
|
||||
try:
|
||||
http_response.raise_for_status()
|
||||
# Cap the compressed bytes read from the network; for zstd,
|
||||
# decompress that capped stream incrementally so the decompressed
|
||||
# data is never materialized in full.
|
||||
source = _CappedLayerReader(http_response.raw, MAX_LAYER_DOWNLOAD_BYTES)
|
||||
if decompress == "gzip":
|
||||
source = gzip.GzipFile(fileobj=source)
|
||||
elif decompress == "zstd":
|
||||
source = zstandard.ZstdDecompressor().stream_reader(source)
|
||||
source = decompressed_budget.wrap(source)
|
||||
tar_stream = tarfile.open(fileobj=source, mode="r|")
|
||||
except (_LayerTooLargeError, _ImageTooLargeError):
|
||||
http_response.close()
|
||||
raise
|
||||
except Exception:
|
||||
http_response.close()
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
yield None
|
||||
return
|
||||
|
||||
with http_response, tar_stream:
|
||||
yield tar_stream
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "rolesanywhere_profile_restricts_session_permissions",
|
||||
"CheckTitle": "IAM Roles Anywhere profiles scope down the vended session permissions",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "rolesanywhere",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsRolesAnywhereProfile",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**IAM Roles Anywhere profiles** that reference an administrative role are assessed for **session scoping**. A profile defining neither an inline `sessionPolicy` nor `managedPolicyArns` vends credentials with the full permissions of its roles. It is flagged only when a referenced role is administrative, since an unscoped session on a least-privilege role is already constrained.",
|
||||
"Risk": "Roles Anywhere profiles bind X.509 certificates to IAM roles. When a profile references an administrative role and does not scope the session, vended credentials carry full administrative permissions. An attacker presenting a valid certificate - or planting a rogue trust anchor and profile - gains durable privileged access that rotating IAM keys does not revoke.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html",
|
||||
"https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateProfile.html",
|
||||
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/security-best-practices.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws rolesanywhere update-profile --profile-id <profile_id> --session-policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}'",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::RolesAnywhere::Profile\n Properties:\n Name: scoped-profile\n Enabled: true\n RoleArns:\n - <role_arn>\n SessionPolicy: '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}' # FIX: scope down the vended session\n```",
|
||||
"Other": "1. Identify the least-privilege actions the workload actually needs\n2. Attach a sessionPolicy or managedPolicyArns to the Roles Anywhere profile that grants only those actions\n3. Prefer purpose-built roles per workload over broad roles referenced by many profiles\n4. Review trust anchors and profiles regularly for entries you did not create",
|
||||
"Terraform": "```hcl\nresource \"aws_rolesanywhere_profile\" \"<example_resource_name>\" {\n name = \"scoped-profile\"\n enabled = true\n role_arns = [<role_arn>]\n session_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [<least_privilege_actions>]\n Resource = [<scoped_resources>]\n }]\n }) # FIX: scope down the vended session\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Attach a session policy or managed policies to every enabled IAM Roles Anywhere profile so the vended credentials are scoped below the referenced role's permissions. Pair each profile with a purpose-built least-privilege role and audit trust anchors and profiles regularly for unexpected entries.",
|
||||
"Url": "https://hub.prowler.com/check/rolesanywhere_profile_restricts_session_permissions"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "A profile is failed only when it is enabled, its session is unscoped, and a referenced role effectively grants administrative (*:*) access. The session-policy set (inline sessionPolicy plus every managedPolicyArns entry, resolved to its policy document) is evaluated as a union: any member granting *:* leaves the session unrestricted. Role classification merges all attached and inline identity-policy documents so explicit denies negate allows across policies, excludes condition-guarded statements (not statically provable), and intersects the result with the role's permissions boundary: a role whose boundary does not grant *:* - or whose boundary document cannot be resolved - is not classified as administrative. Disabled profiles, scoped profiles, and profiles with no role identified as administrative are reported as PASS. Referenced roles absent from the IAM inventory (for example cross-account roles or denied ListRoles) and policy documents that could not be collected are treated as non-administrative to avoid false positives."
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import json
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.aws.services.iam.iam_client import iam_client
|
||||
from prowler.providers.aws.services.iam.lib.policy import check_full_service_access
|
||||
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import (
|
||||
rolesanywhere_client,
|
||||
)
|
||||
|
||||
# AWS-managed AdministratorAccess ARN suffix, partition-agnostic
|
||||
# (arn:aws:..., arn:aws-cn:..., arn:aws-us-gov:...).
|
||||
ADMIN_POLICY_ARN_SUFFIX = ":iam::aws:policy/AdministratorAccess"
|
||||
|
||||
# Synthetic statement equivalent to the AWS-managed AdministratorAccess
|
||||
# document, used when a policy is identified by that ARN but its document was
|
||||
# not collected by the IAM service.
|
||||
_ADMIN_STATEMENT = {"Effect": "Allow", "Action": "*", "Resource": "*"}
|
||||
|
||||
|
||||
def _grants_full_access(document) -> bool:
|
||||
"""Return True when a policy document grants administrative (``*:*``) access.
|
||||
|
||||
Args:
|
||||
document: Decoded IAM policy document, or None when unavailable.
|
||||
"""
|
||||
if not document:
|
||||
return False
|
||||
try:
|
||||
return check_full_service_access("*", document)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _full_access_status(documents) -> bool | None:
|
||||
"""Return whether a policy-document set grants ``*:*``, or None when unknown.
|
||||
|
||||
Merges every condition-free statement across the given documents into a
|
||||
single evaluation so an explicit deny in one document negates an allow in
|
||||
another (Deny > Allow via the shared policy-evaluation helpers).
|
||||
|
||||
Unresolved or malformed documents and context-dependent semantics cannot
|
||||
produce a definitive classification and propagate as None:
|
||||
|
||||
- a document that is missing or not a well-formed statement container;
|
||||
- a ``Condition``-guarded Deny that could negate an otherwise proven
|
||||
full-access grant;
|
||||
- a ``Condition``-guarded Allow that could grant full access not proven
|
||||
by the unconditional statements.
|
||||
|
||||
Args:
|
||||
documents: Iterable of decoded IAM policy documents (None members mark
|
||||
documents that could not be resolved).
|
||||
"""
|
||||
statements = []
|
||||
conditional_effects = set()
|
||||
|
||||
for document in documents:
|
||||
if not isinstance(document, dict) or "Statement" not in document:
|
||||
return None
|
||||
|
||||
document_statements = document.get("Statement", [])
|
||||
if not isinstance(document_statements, list):
|
||||
document_statements = [document_statements]
|
||||
|
||||
for statement in document_statements:
|
||||
if not isinstance(statement, dict):
|
||||
return None
|
||||
|
||||
effect = str(statement.get("Effect", "")).lower()
|
||||
if (
|
||||
effect not in {"allow", "deny"}
|
||||
or not ("Action" in statement or "NotAction" in statement)
|
||||
or not ("Resource" in statement or "NotResource" in statement)
|
||||
):
|
||||
return None
|
||||
|
||||
if statement.get("Condition"):
|
||||
conditional_effects.add(effect)
|
||||
else:
|
||||
statements.append(statement)
|
||||
|
||||
grants_full_access = _grants_full_access({"Statement": statements})
|
||||
if (grants_full_access and "deny" in conditional_effects) or (
|
||||
not grants_full_access and "allow" in conditional_effects
|
||||
):
|
||||
return None
|
||||
|
||||
return grants_full_access
|
||||
|
||||
|
||||
def _role_is_privileged(role, policies) -> bool | None:
|
||||
"""Return whether an IAM role is administrative, or None when unknown.
|
||||
|
||||
Effective permissions are the intersection of the role's identity policies
|
||||
(attached and inline, evaluated together) and its permissions boundary.
|
||||
Unresolved policy documents, malformed policies, and condition-guarded
|
||||
statements that could change the outcome propagate as None instead of
|
||||
being collapsed into a definitive classification.
|
||||
|
||||
Args:
|
||||
role: An ``iam_service.Role`` referenced by a Roles Anywhere profile.
|
||||
policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client.
|
||||
"""
|
||||
documents = []
|
||||
for attached in role.attached_policies:
|
||||
policy_arn = attached.get("PolicyArn", "")
|
||||
document = getattr(policies.get(policy_arn), "document", None)
|
||||
if policy_arn.endswith(ADMIN_POLICY_ARN_SUFFIX) and not document:
|
||||
documents.append({"Statement": [_ADMIN_STATEMENT]})
|
||||
else:
|
||||
documents.append(document)
|
||||
for inline_name in role.inline_policies:
|
||||
policy = policies.get(f"{role.arn}:policy/{inline_name}")
|
||||
documents.append(getattr(policy, "document", None))
|
||||
|
||||
identity_status = _full_access_status(documents)
|
||||
if identity_status is False:
|
||||
# Identity policies provably do not grant *:*; no boundary can widen them.
|
||||
return False
|
||||
|
||||
boundary = getattr(role, "permissions_boundary", None)
|
||||
if not boundary:
|
||||
return identity_status
|
||||
boundary_arn = (
|
||||
boundary.get("PermissionsBoundaryArn", "") if isinstance(boundary, dict) else ""
|
||||
)
|
||||
if boundary_arn.endswith(ADMIN_POLICY_ARN_SUFFIX):
|
||||
# An AdministratorAccess boundary restricts nothing.
|
||||
return identity_status
|
||||
|
||||
boundary_status = _full_access_status(
|
||||
[getattr(policies.get(boundary_arn), "document", None)]
|
||||
)
|
||||
if boundary_status is False:
|
||||
# The boundary provably does not grant *:*: the intersection cannot be
|
||||
# administrative regardless of the identity policies.
|
||||
return False
|
||||
if boundary_status is None:
|
||||
return None
|
||||
return identity_status
|
||||
|
||||
|
||||
def _session_is_scoped(profile, policies) -> bool | None:
|
||||
"""Return whether session policies restrict permissions, or None when unknown.
|
||||
|
||||
AWS evaluates the inline ``sessionPolicy`` and every ``managedPolicyArns``
|
||||
entry together as a single session-policy category, so the complete set is
|
||||
merged into one evaluation: the session is scoped only when at least one
|
||||
session policy exists and the set does not grant ``*:*``. Managed entries
|
||||
are resolved through the collected IAM policies. An invalid inline policy
|
||||
or an unresolved managed policy does not prove that the session is
|
||||
restricted and propagates as None.
|
||||
|
||||
Args:
|
||||
profile: A ``rolesanywhere_service.Profile``.
|
||||
policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client.
|
||||
"""
|
||||
if not profile.session_policy and not profile.managed_policy_arns:
|
||||
return False
|
||||
|
||||
documents = []
|
||||
if profile.session_policy:
|
||||
try:
|
||||
documents.append(json.loads(profile.session_policy))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
for arn in profile.managed_policy_arns or []:
|
||||
if arn.endswith(ADMIN_POLICY_ARN_SUFFIX):
|
||||
documents.append({"Statement": [_ADMIN_STATEMENT]})
|
||||
else:
|
||||
documents.append(getattr(policies.get(arn), "document", None))
|
||||
|
||||
grants_full_access = _full_access_status(documents)
|
||||
return None if grants_full_access is None else not grants_full_access
|
||||
|
||||
|
||||
class rolesanywhere_profile_restricts_session_permissions(Check):
|
||||
"""Flag Roles Anywhere profiles that vend an unscoped session on a privileged role.
|
||||
|
||||
A Roles Anywhere profile that does not restrict the session with an inline
|
||||
``sessionPolicy`` or ``managedPolicyArns`` vends temporary credentials
|
||||
carrying the full permissions of every role it references. This is only a
|
||||
real risk when a referenced role is itself administrative: any certificate
|
||||
accepted by the trust anchor then wields administrative permissions, turning
|
||||
the profile into a durable privileged-access path that surviving key rotation
|
||||
does not remove. Profiles that scope the session, whose referenced roles were
|
||||
proven not administrative, or that are disabled are reported as PASS. When
|
||||
session scoping or role permissions cannot be evaluated (unresolved or
|
||||
invalid policy documents, condition-guarded grants, unknown roles), the
|
||||
report is MANUAL rather than a proven outcome.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Evaluate session-permission scoping for Roles Anywhere profiles.
|
||||
|
||||
Returns:
|
||||
list[Check_Report_AWS]: One report per Roles Anywhere profile. FAIL
|
||||
for enabled, unscoped profiles that reference a proven administrative
|
||||
role; MANUAL when session scoping or role permissions could not be
|
||||
evaluated; PASS for scoped profiles, profiles whose roles were proven
|
||||
not administrative, and disabled profiles.
|
||||
"""
|
||||
findings = []
|
||||
roles_by_arn = {role.arn: role for role in iam_client.roles}
|
||||
for profile in rolesanywhere_client.profiles.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=profile)
|
||||
role_statuses = {
|
||||
arn: (
|
||||
_role_is_privileged(roles_by_arn[arn], iam_client.policies)
|
||||
if arn in roles_by_arn
|
||||
else None
|
||||
)
|
||||
for arn in profile.role_arns
|
||||
}
|
||||
privileged_role_arns = [
|
||||
arn for arn, status in role_statuses.items() if status is True
|
||||
]
|
||||
unknown_role_arns = [
|
||||
arn for arn, status in role_statuses.items() if status is None
|
||||
]
|
||||
session_scoped = _session_is_scoped(profile, iam_client.policies)
|
||||
|
||||
if not profile.enabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} is disabled and "
|
||||
"cannot vend session credentials."
|
||||
)
|
||||
elif session_scoped is True:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} restricts vended "
|
||||
"session permissions with a session policy or managed policies."
|
||||
)
|
||||
elif session_scoped is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} session scoping "
|
||||
"could not be evaluated because an inline or managed session "
|
||||
"policy was invalid or unresolved."
|
||||
)
|
||||
elif privileged_role_arns:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} does not scope down "
|
||||
"sessions and references administrative role(s) "
|
||||
f"{', '.join(privileged_role_arns)}; certificates authenticated "
|
||||
"through it inherit administrative permissions, enabling durable "
|
||||
"privileged access."
|
||||
)
|
||||
elif unknown_role_arns:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} does not scope down "
|
||||
"sessions, and the effective permissions of referenced role(s) "
|
||||
f"{', '.join(unknown_role_arns)} could not be evaluated."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} does not scope down "
|
||||
"sessions, but no referenced role was identified as "
|
||||
"administrative; scoping the session is recommended as "
|
||||
"defense-in-depth."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -11,9 +11,7 @@ class RolesAnywhere(AWSService):
|
||||
def __init__(self, provider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.trust_anchors = {}
|
||||
self.profiles = {}
|
||||
self.__threading_call__(self._list_trust_anchors)
|
||||
self.__threading_call__(self._list_profiles)
|
||||
|
||||
def _list_trust_anchors(self, regional_client):
|
||||
logger.info("RolesAnywhere - Listing Trust Anchors...")
|
||||
@@ -54,53 +52,6 @@ class RolesAnywhere(AWSService):
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_profiles(self, regional_client):
|
||||
"""List and cache IAM Roles Anywhere profiles for one AWS Region.
|
||||
|
||||
Args:
|
||||
regional_client: Roles Anywhere client for the audited Region.
|
||||
"""
|
||||
logger.info("RolesAnywhere - Listing Profiles...")
|
||||
try:
|
||||
paginator = regional_client.get_paginator("list_profiles")
|
||||
for page in paginator.paginate():
|
||||
for profile in page.get("profiles", []):
|
||||
arn = profile.get("profileArn", "")
|
||||
if not arn:
|
||||
continue
|
||||
if self.audit_resources and not is_resource_filtered(
|
||||
arn, self.audit_resources
|
||||
):
|
||||
continue
|
||||
tags = []
|
||||
try:
|
||||
tags = regional_client.list_tags_for_resource(
|
||||
resourceArn=arn
|
||||
).get("tags", [])
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
self.profiles[arn] = Profile(
|
||||
arn=arn,
|
||||
id=profile.get("profileId", ""),
|
||||
name=profile.get("name", ""),
|
||||
region=regional_client.region,
|
||||
enabled=profile.get("enabled", False),
|
||||
role_arns=profile.get("roleArns", []) or [],
|
||||
session_policy=profile.get("sessionPolicy", "") or "",
|
||||
managed_policy_arns=profile.get("managedPolicyArns", []) or [],
|
||||
duration_seconds=profile.get("durationSeconds", 0) or 0,
|
||||
accept_role_session_name=profile.get(
|
||||
"acceptRoleSessionName", False
|
||||
),
|
||||
tags=tags,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class TrustAnchor(BaseModel):
|
||||
arn: str
|
||||
@@ -111,19 +62,3 @@ class TrustAnchor(BaseModel):
|
||||
source_type: str = ""
|
||||
acm_pca_arn: str = ""
|
||||
tags: List[Dict[str, str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
"""Represent an IAM Roles Anywhere profile."""
|
||||
|
||||
arn: str
|
||||
id: str
|
||||
name: str
|
||||
region: str
|
||||
enabled: bool = False
|
||||
role_arns: List[str] = Field(default_factory=list)
|
||||
session_policy: str = ""
|
||||
managed_policy_arns: List[str] = Field(default_factory=list)
|
||||
duration_seconds: int = 0
|
||||
accept_role_session_name: bool = False
|
||||
tags: List[Dict[str, str]] = Field(default_factory=list)
|
||||
|
||||
@@ -17,8 +17,6 @@ class IAM(GCPService):
|
||||
self.service_accounts = []
|
||||
self._get_service_accounts()
|
||||
self._get_service_accounts_keys()
|
||||
self.workload_identity_pool_providers = []
|
||||
self._get_workload_identity_pool_providers()
|
||||
|
||||
def _get_service_accounts(self):
|
||||
for project_id in self.project_ids:
|
||||
@@ -89,94 +87,6 @@ class IAM(GCPService):
|
||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_workload_identity_pool_providers(self):
|
||||
for project_id in self.project_ids:
|
||||
try:
|
||||
pools_request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.list(parent=f"projects/{project_id}/locations/global")
|
||||
)
|
||||
while pools_request is not None:
|
||||
pools_response = pools_request.execute(
|
||||
num_retries=DEFAULT_RETRY_ATTEMPTS
|
||||
)
|
||||
for pool in pools_response.get("workloadIdentityPools", []):
|
||||
self._get_providers_for_pool(project_id, pool)
|
||||
pools_request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.list_next(
|
||||
previous_request=pools_request,
|
||||
previous_response=pools_response,
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_providers_for_pool(self, project_id, pool):
|
||||
try:
|
||||
pool_name = pool.get("name", "")
|
||||
pool_id = pool_name.split("/")[-1]
|
||||
# A provider can remain ACTIVE while its parent pool is disabled or
|
||||
# soft-deleted; a disabled pool cannot vend credentials, so the
|
||||
# pool's effective availability must travel with the provider.
|
||||
pool_disabled = (
|
||||
pool.get("disabled", False) or pool.get("state", "ACTIVE") != "ACTIVE"
|
||||
)
|
||||
request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.providers()
|
||||
.list(parent=pool_name)
|
||||
)
|
||||
while request is not None:
|
||||
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
|
||||
for provider in response.get("workloadIdentityPoolProviders", []):
|
||||
provider_type = next(
|
||||
(
|
||||
key
|
||||
for key in ("oidc", "aws", "saml", "x509")
|
||||
if key in provider
|
||||
),
|
||||
"",
|
||||
)
|
||||
self.workload_identity_pool_providers.append(
|
||||
WorkloadIdentityPoolProvider(
|
||||
name=provider.get("name", ""),
|
||||
id=provider.get("name", "").split("/")[-1],
|
||||
pool_id=pool_id,
|
||||
pool_disabled=pool_disabled,
|
||||
project_id=project_id,
|
||||
state=provider.get("state", ""),
|
||||
disabled=provider.get("disabled", False),
|
||||
attribute_condition=provider.get("attributeCondition", ""),
|
||||
attribute_mapping=provider.get("attributeMapping", {})
|
||||
or {},
|
||||
provider_type=provider_type,
|
||||
issuer_uri=(provider.get("oidc", {}) or {}).get(
|
||||
"issuerUri", ""
|
||||
),
|
||||
display_name=provider.get("displayName", ""),
|
||||
)
|
||||
)
|
||||
request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.providers()
|
||||
.list_next(previous_request=request, previous_response=response)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class Key(BaseModel):
|
||||
name: str
|
||||
@@ -196,25 +106,6 @@ class ServiceAccount(BaseModel):
|
||||
disabled: bool = False
|
||||
|
||||
|
||||
class WorkloadIdentityPoolProvider(BaseModel):
|
||||
"""Represent a GCP Workload Identity Federation pool provider."""
|
||||
|
||||
name: str
|
||||
id: str
|
||||
pool_id: str
|
||||
# True when the parent pool is disabled or not ACTIVE; such a pool cannot
|
||||
# vend credentials regardless of the provider's own state.
|
||||
pool_disabled: bool = False
|
||||
project_id: str
|
||||
state: str = ""
|
||||
disabled: bool = False
|
||||
attribute_condition: str = ""
|
||||
attribute_mapping: dict = {}
|
||||
provider_type: str = ""
|
||||
issuer_uri: str = ""
|
||||
display_name: str = ""
|
||||
|
||||
|
||||
class AccessApproval(GCPService):
|
||||
def __init__(self, provider: GcpProvider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"Provider": "gcp",
|
||||
"CheckID": "iam_workload_identity_pool_provider_attribute_condition",
|
||||
"CheckTitle": "Workload Identity Federation providers trusting a multi-tenant issuer enforce an attribute condition",
|
||||
"CheckType": [],
|
||||
"ServiceName": "iam",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "iam.googleapis.com/WorkloadIdentityPoolProvider",
|
||||
"Description": "**Workload Identity Federation providers** define an `attributeCondition` (CEL) restricting which external identities may impersonate Google Cloud principals. When a provider trusts a **multi-tenant issuer** (GitHub Actions, GitLab.com and other shared issuers), omitting the condition trusts every identity that issuer can mint. Providers trusting a dedicated single-tenant issuer are not flagged.",
|
||||
"Risk": "A provider trusting a multi-tenant issuer without an attribute condition accepts any external identity from that issuer - for example any GitHub repository when the issuer is GitHub Actions. An attacker controlling any tenant on that platform can authenticate through the provider and exchange tokens for federated credentials. Because no key is stored, this access survives credential rotation.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://cloud.google.com/iam/docs/workload-identity-federation",
|
||||
"https://cloud.google.com/iam/docs/workload-identity-federation#mapping",
|
||||
"https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "gcloud iam workload-identity-pools providers update-<oidc|aws|saml|x509> <PROVIDER_ID> --location=global --workload-identity-pool=<POOL_ID> --attribute-condition=\"<CEL_condition>\" # use the update subcommand matching the provider type",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. In the Google Cloud console, go to IAM & Admin > Workload Identity Federation\n2. Open the affected pool and provider\n3. Set an attribute condition (CEL) restricting the allowed external identities, using an assertion that fits the provider type: OIDC by subject or claim (assertion.sub), SAML by NameID or attribute (assertion.subject or assertion.attributes[...]), AWS by account/role (assertion.account or assertion.arn), X.509 by certificate subject\n4. For OIDC providers, also restrict allowedAudiences to your own audience\n5. Review providers regularly for entries you did not create",
|
||||
"Terraform": "```hcl\nresource \"google_iam_workload_identity_pool_provider\" \"example\" {\n workload_identity_pool_id = \"my-pool\"\n workload_identity_pool_provider_id = \"my-provider\"\n attribute_condition = \"assertion.repository_owner == 'my-org'\" # FIX: restrict trusted identities\n\n # Declare exactly one of oidc {}, aws {}, saml {}, or x509 {} to match the provider type, e.g.:\n oidc {\n issuer_uri = \"https://token.actions.githubusercontent.com\"\n allowed_audiences = [\"https://my-audience.example.com\"]\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set an attribute condition on any active Workload Identity Federation provider that trusts a multi-tenant issuer so only the intended external identities can impersonate Google Cloud principals, and pair OIDC providers with a restricted audience. Providers trusting a dedicated single-tenant issuer should still add a condition as defense-in-depth. Audit pools and providers regularly for unexpected entries.",
|
||||
"Url": "https://hub.prowler.com/check/iam_workload_identity_pool_provider_attribute_condition"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Only active providers that trust a known multi-tenant issuer (GitHub Actions, GitLab.com, Google, HCP Terraform) are failed when they omit an attribute condition; providers trusting a dedicated single-tenant issuer, AWS/SAML/X.509 providers, and disabled or non-ACTIVE providers are reported as PASS. This check verifies that an attribute condition is present; it does not evaluate whether the condition's expression is sufficiently restrictive."
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_GCP
|
||||
from prowler.providers.gcp.services.iam.iam_client import iam_client
|
||||
|
||||
# OIDC issuers whose tokens are minted for many independent tenants (any GitHub
|
||||
# repository, any GitLab project, any Google account, ...). A provider that
|
||||
# trusts one of these without an ``attributeCondition`` accepts identities
|
||||
# outside the operator's control, so omitting the condition genuinely expands
|
||||
# trust. A dedicated, single-tenant issuer only vends tokens to the operator's
|
||||
# own workloads, so an attribute condition there is defense-in-depth rather than
|
||||
# a requirement (see Google's guidance for GitHub and other shared issuers).
|
||||
MULTI_TENANT_OIDC_ISSUER_HOSTS = {
|
||||
"token.actions.githubusercontent.com", # GitHub Actions (any repository)
|
||||
"gitlab.com", # GitLab.com SaaS (any project)
|
||||
"accounts.google.com", # any Google account
|
||||
"app.terraform.io", # HCP Terraform (any organization)
|
||||
}
|
||||
|
||||
|
||||
def _is_multi_tenant_issuer(issuer_uri: str) -> bool:
|
||||
"""Return True when the OIDC issuer is a known multi-tenant/shared issuer."""
|
||||
if not issuer_uri:
|
||||
return False
|
||||
# hostname lowercases and strips port/userinfo (gitlab.com:443, user@host);
|
||||
# fall back to the raw string for bare hosts without a scheme.
|
||||
host = urlparse(issuer_uri).hostname or issuer_uri.lower()
|
||||
return host in MULTI_TENANT_OIDC_ISSUER_HOSTS
|
||||
|
||||
|
||||
class iam_workload_identity_pool_provider_attribute_condition(Check):
|
||||
"""Ensure WIF providers trusting a multi-tenant issuer enforce an attribute condition.
|
||||
|
||||
A workload identity pool provider that trusts a multi-tenant issuer (GitHub
|
||||
Actions, GitLab.com, ...) without an ``attributeCondition`` accepts every
|
||||
external identity that issuer can mint. An attacker controlling any tenant on
|
||||
that platform can then authenticate through the provider and exchange tokens
|
||||
for federated credentials, surviving credential rotation. Providers that
|
||||
enforce an attribute condition, that trust a dedicated single-tenant issuer,
|
||||
that are not OIDC-based, that are disabled/inactive, or whose parent pool is
|
||||
disabled are reported as PASS.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_GCP]:
|
||||
"""Evaluate the attribute condition of each Workload Identity provider.
|
||||
|
||||
Returns:
|
||||
list[Check_Report_GCP]: One report per workload identity pool
|
||||
provider. FAIL for active providers that trust a multi-tenant issuer
|
||||
without an attribute condition; PASS for providers that enforce one,
|
||||
trust a dedicated issuer, are not OIDC-based, or are
|
||||
disabled/inactive.
|
||||
"""
|
||||
findings = []
|
||||
for provider in iam_client.workload_identity_pool_providers:
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=provider,
|
||||
resource_id=provider.name,
|
||||
resource_name=provider.display_name or provider.id,
|
||||
location="global",
|
||||
)
|
||||
if provider.pool_disabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} belongs "
|
||||
f"to the disabled pool {provider.pool_id}, which cannot vend "
|
||||
"credentials."
|
||||
)
|
||||
elif provider.disabled or provider.state != "ACTIVE":
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} is not active and cannot vend credentials."
|
||||
)
|
||||
elif provider.attribute_condition:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} enforces an attribute condition."
|
||||
)
|
||||
elif _is_multi_tenant_issuer(provider.issuer_uri):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} trusts the multi-tenant issuer "
|
||||
f"{provider.issuer_uri} without an attribute condition, so any "
|
||||
"identity from that issuer can authenticate through this provider."
|
||||
)
|
||||
elif provider.provider_type != "oidc":
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} is not an OIDC provider trusting a "
|
||||
"multi-tenant issuer; an attribute condition is recommended as "
|
||||
"defense-in-depth but not required."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} trusts a dedicated issuer; an attribute "
|
||||
"condition is recommended as defense-in-depth but not required."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"Provider": "huaweicloud",
|
||||
"CheckID": "vpc_security_group_open_egress",
|
||||
"CheckTitle": "VPC security groups do not allow open egress to the internet",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vpc",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "HUAWEICLOUD::VPC::SecurityGroup",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Security groups should not allow unrestricted egress to all destinations. Huawei Cloud represents unrestricted destinations as 0.0.0.0/0, ::/0, or an empty remote IP prefix without a remote security group or address group. Open egress allows instances to reach any destination on the internet, increasing the risk of data exfiltration and command-and-control communication.",
|
||||
"Risk": "Unrestricted egress rules allow instances to communicate with any external destination. This increases the risk of data exfiltration, unauthorized outbound connections, and command-and-control channel establishment by compromised instances.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.huaweicloud.com/intl/en-us/usermanual-vpc/vpc_SecurityGroup_0001.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "hcloud VPC UpdateSecurityGroupRule --security_group_rule_id <rule_id> --remote_ip_prefix <restricted_cidr>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Log on to the Huawei Cloud console.\n2. Choose VPC.\n3. Click Security Groups.\n4. Select the security group.\n5. Edit each allow egress rule whose destination is 0.0.0.0/0, ::/0, or empty and unscoped.\n6. Restrict the destination to required IP ranges or an appropriate security group or address group.\n7. Click OK.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Restrict allow egress rules to required destination IP ranges, security groups, or address groups instead of allowing all IPv4 or IPv6 destinations.",
|
||||
"Url": "https://hub.prowler.com/check/vpc_security_group_open_egress"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
from prowler.lib.check.models import Check, CheckReportHuaweiCloud
|
||||
from prowler.providers.huaweicloud.services.vpc.vpc_client import vpc_client
|
||||
|
||||
|
||||
class vpc_security_group_open_egress(Check):
|
||||
"""Check if VPC security groups allow unrestricted egress to the internet."""
|
||||
|
||||
def execute(self) -> list[CheckReportHuaweiCloud]:
|
||||
"""Execute the unrestricted egress check.
|
||||
|
||||
Returns:
|
||||
list[CheckReportHuaweiCloud]: Reports for the evaluated security groups.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for sg in vpc_client.security_groups.values():
|
||||
report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=sg)
|
||||
report.region = sg.region
|
||||
report.resource_id = sg.id
|
||||
report.resource_arn = (
|
||||
f"huaweicloud:vpc:{sg.region}:"
|
||||
f"{vpc_client.audited_account}:security-group/{sg.id}"
|
||||
)
|
||||
|
||||
open_egress_destination = None
|
||||
for rule in sg.rules:
|
||||
if (
|
||||
rule.direction != "egress"
|
||||
or rule.action != "allow"
|
||||
or rule.remote_group_id
|
||||
or rule.remote_address_group_id
|
||||
):
|
||||
continue
|
||||
if rule.remote_ip_prefix in ("0.0.0.0/0", "::/0"):
|
||||
open_egress_destination = rule.remote_ip_prefix
|
||||
break
|
||||
if not rule.remote_ip_prefix:
|
||||
open_egress_destination = "all destinations"
|
||||
break
|
||||
|
||||
if open_egress_destination:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Security group {sg.name} ({sg.id}) allows open egress "
|
||||
f"({open_egress_destination}) to the internet."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Security group {sg.name} ({sg.id}) does not allow open egress "
|
||||
f"to the internet."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -91,8 +91,6 @@ class VPC(HuaweiCloudService):
|
||||
id=getattr(rule_data, "id", None) or "",
|
||||
direction=getattr(rule_data, "direction", None)
|
||||
or "",
|
||||
action=getattr(rule_data, "action", None)
|
||||
or "allow",
|
||||
protocol=getattr(rule_data, "protocol", None)
|
||||
or "",
|
||||
ethertype=getattr(rule_data, "ethertype", None)
|
||||
@@ -111,12 +109,6 @@ class VPC(HuaweiCloudService):
|
||||
rule_data, "remote_group_id", None
|
||||
)
|
||||
or "",
|
||||
remote_address_group_id=getattr(
|
||||
rule_data,
|
||||
"remote_address_group_id",
|
||||
None,
|
||||
)
|
||||
or "",
|
||||
description=getattr(
|
||||
rule_data, "description", None
|
||||
)
|
||||
@@ -156,14 +148,12 @@ class SecurityGroupRule(HuaweiCloudBaseModel):
|
||||
|
||||
id: str
|
||||
direction: str
|
||||
action: str = "allow"
|
||||
protocol: str
|
||||
ethertype: str
|
||||
port_range_min: Optional[int] = None
|
||||
port_range_max: Optional[int] = None
|
||||
remote_ip_prefix: str = ""
|
||||
remote_group_id: str = ""
|
||||
remote_address_group_id: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
@@ -191,16 +181,13 @@ def rule_source_is_open(rule: SecurityGroupRule) -> bool:
|
||||
|
||||
Huawei Cloud represents "any source" in two ways: an explicit ``0.0.0.0/0``
|
||||
(or ``::/0``) in ``remote_ip_prefix``, or leaving both ``remote_ip_prefix``
|
||||
and both group identifiers empty. Rules that reference a security group or
|
||||
address group are NOT open even when ``remote_ip_prefix`` is empty.
|
||||
and ``remote_group_id`` empty. Rules that reference another security group
|
||||
via ``remote_group_id`` are NOT open even when ``remote_ip_prefix`` is
|
||||
empty.
|
||||
"""
|
||||
if rule.remote_ip_prefix in ("0.0.0.0/0", "::/0"):
|
||||
return True
|
||||
return (
|
||||
not rule.remote_ip_prefix
|
||||
and not rule.remote_group_id
|
||||
and not rule.remote_address_group_id
|
||||
)
|
||||
return not rule.remote_ip_prefix and not rule.remote_group_id
|
||||
|
||||
|
||||
def rule_covers_all_ports(rule: SecurityGroupRule) -> bool:
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.stackit.services.ske.ske_service import SKEService
|
||||
|
||||
ske_client = SKEService(Provider.get_global_provider())
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"Provider": "stackit",
|
||||
"CheckID": "ske_cluster_no_public_endpoint",
|
||||
"CheckTitle": "SKE clusters do not expose their Kubernetes API endpoint to the internet",
|
||||
"CheckType": [],
|
||||
"ServiceName": "ske",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "container",
|
||||
"Description": "The **Kubernetes API server** of an SKE cluster should not be reachable from the entire internet. Enable the **ACL extension** to restrict access to a known set of source CIDRs, or run the control plane inside a **STACKIT Network Area (SNA)** so that it is not published on the internet at all.",
|
||||
"Risk": "A publicly reachable **Kubernetes API server** lets anyone on the internet attempt authentication, exploit unpatched control plane vulnerabilities, or brute-force credentials. Combined with leaked or weak credentials, this can lead to **full cluster takeover**, workload tampering, and data exfiltration.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/enhance-the-security-of-your-cluster/",
|
||||
"https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/enable-private-clusters/",
|
||||
"https://docs.stackit.cloud/products/runtime/kubernetes-engine/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. In the STACKIT Portal open Kubernetes Engine (SKE) and select the affected cluster. 2. Open the cluster configuration and enable the ACL extension. 3. Add only the source CIDRs that must reach the Kubernetes API (for example your office or VPN egress ranges) and make sure 0.0.0.0/0 and ::/0 are not present. 4. Save the cluster configuration and re-run Prowler to confirm the finding is resolved.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "**Restrict access to the Kubernetes API.** Enable the **SKE ACL extension** and allowlist only the source CIDRs that legitimately need to reach the API server, never `0.0.0.0/0` or `::/0`. Where available, place the control plane in a **STACKIT Network Area** so the endpoint is not exposed to the internet.",
|
||||
"Url": "https://hub.prowler.com/check/ske_cluster_no_public_endpoint"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "A cluster passes when its control plane access scope is SNA, or when the ACL extension is enabled and its allowed CIDR list contains no unrestricted range. A cluster with the ACL extension disabled is reported as FAIL because the Kubernetes API accepts connections from any source address. The SNA private control plane is not generally available yet, so most clusters are expected to rely on the ACL extension."
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
from prowler.lib.check.models import Check, CheckReportStackIT
|
||||
from prowler.providers.stackit.services.ske.ske_client import ske_client
|
||||
|
||||
|
||||
class ske_cluster_no_public_endpoint(Check):
|
||||
"""
|
||||
Check if SKE clusters expose their Kubernetes API endpoint to the internet.
|
||||
|
||||
A cluster passes when its control plane is confined to a STACKIT Network
|
||||
Area, or when the ACL extension restricts the Kubernetes API to a set of
|
||||
source CIDRs that does not include an unrestricted range.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportStackIT]:
|
||||
"""
|
||||
Execute the check for all SKE clusters in the StackIT project.
|
||||
|
||||
Returns:
|
||||
list: A list of CheckReportStackIT findings
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for cluster in ske_client.clusters:
|
||||
report = CheckReportStackIT(
|
||||
metadata=self.metadata(),
|
||||
resource=cluster,
|
||||
)
|
||||
|
||||
if cluster.has_public_endpoint():
|
||||
report.status = "FAIL"
|
||||
unrestricted_cidrs = cluster.unrestricted_cidrs()
|
||||
if unrestricted_cidrs:
|
||||
report.status_extended = (
|
||||
f"SKE cluster {cluster.name} exposes its Kubernetes API "
|
||||
f"endpoint to the internet because its ACL allows "
|
||||
f"unrestricted access from {', '.join(unrestricted_cidrs)}."
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
f"SKE cluster {cluster.name} exposes its Kubernetes API "
|
||||
f"endpoint to the internet because the ACL extension is "
|
||||
f"not enabled."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
if cluster.has_private_control_plane():
|
||||
report.status_extended = (
|
||||
f"SKE cluster {cluster.name} has a private control plane "
|
||||
f"and its Kubernetes API endpoint is not reachable from "
|
||||
f"the internet."
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
f"SKE cluster {cluster.name} restricts access to its "
|
||||
f"Kubernetes API endpoint to "
|
||||
f"{len(cluster.allowed_cidrs)} allowed CIDR(s)."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -1,305 +0,0 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr
|
||||
|
||||
# Source ranges that place no restriction on who may reach the Kubernetes API.
|
||||
UNRESTRICTED_CIDRS = frozenset({"0.0.0.0/0", "::/0"})
|
||||
|
||||
# Control plane access scope that keeps the Kubernetes API inside a STACKIT
|
||||
# Network Area instead of publishing it on the internet.
|
||||
PRIVATE_ACCESS_SCOPE = "SNA"
|
||||
|
||||
|
||||
class SKEService:
|
||||
"""
|
||||
StackIT Kubernetes Engine (SKE) service class to handle cluster operations.
|
||||
|
||||
This service uses the StackIT Python SDK to access SKE resources.
|
||||
Authentication is delegated to the SDK, which signs the RSA challenge
|
||||
in the configured service account key and refreshes access tokens
|
||||
internally for the life of the scan.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: StackitProvider):
|
||||
"""
|
||||
Initialize the SKE service.
|
||||
|
||||
Args:
|
||||
provider: The StackIT provider instance
|
||||
"""
|
||||
self.provider = provider
|
||||
self.project_id = provider.identity.project_id
|
||||
self.service_account_key_path = provider.session.get("service_account_key_path")
|
||||
|
||||
# Generate regional clients (AWS pattern)
|
||||
self.regional_clients = provider.generate_regional_clients("ske")
|
||||
self.audited_regions = provider.identity.audited_regions
|
||||
|
||||
# Initialize cluster list
|
||||
self.clusters: list[Cluster] = []
|
||||
|
||||
# Fetch resources from all regions
|
||||
self._fetch_all_regions()
|
||||
|
||||
def _fetch_all_regions(self):
|
||||
"""Fetch SKE clusters from all audited regions.
|
||||
|
||||
A project is not necessarily provisioned in every StackIT region. A
|
||||
region where the project does not exist answers the SKE endpoints with
|
||||
HTTP 404 (``resource not found: project``). That is expected, so the
|
||||
region is skipped and the scan continues with the remaining regions
|
||||
instead of aborting.
|
||||
|
||||
A project that enabled SKE in some regions but not others answers the
|
||||
remaining ones with HTTP 403 ``Service not enabled``; that region is
|
||||
skipped too. Genuine credential and permission failures still propagate
|
||||
via ``handle_api_error`` so a misconfigured account fails loudly.
|
||||
"""
|
||||
for region, client in self.regional_clients.items():
|
||||
try:
|
||||
self._list_clusters(client, region)
|
||||
except Exception as error:
|
||||
if getattr(error, "status", None) == 404:
|
||||
logger.info(
|
||||
f"StackIT project {self.project_id} has no SKE presence "
|
||||
f"in region {region} (404 resource not found); skipping "
|
||||
f"this region."
|
||||
)
|
||||
continue
|
||||
if self._is_service_not_enabled(error):
|
||||
logger.info(
|
||||
f"StackIT project {self.project_id} has SKE disabled in "
|
||||
f"region {region} (403 service not enabled); skipping "
|
||||
f"this region."
|
||||
)
|
||||
continue
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _extract_items(response, endpoint_name: str) -> list:
|
||||
"""Extract the items list from a StackIT SDK response.
|
||||
|
||||
Handles three response shapes safely:
|
||||
- SDK model exposing an ``items`` attribute (not the ``dict.items`` method)
|
||||
- Raw ``dict`` with an ``"items"`` key
|
||||
- Plain ``list``
|
||||
|
||||
``isinstance(response, dict)`` is checked first because ``dict`` has an
|
||||
``items`` *method*; ``hasattr(response, "items")`` is otherwise True for
|
||||
plain dicts and silently returns the bound method.
|
||||
"""
|
||||
if isinstance(response, dict):
|
||||
return response.get("items", [])
|
||||
if isinstance(response, list):
|
||||
return response
|
||||
items_attr = getattr(response, "items", None)
|
||||
if items_attr is not None and not callable(items_attr):
|
||||
return items_attr
|
||||
logger.warning(
|
||||
f"Unexpected response type from {endpoint_name}: {type(response)}"
|
||||
)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_field(item, *keys, default=None):
|
||||
"""Read a field from an SDK model (attribute) or a raw ``dict`` (key).
|
||||
|
||||
``_extract_items`` yields either SDK models or raw dicts, so the nested
|
||||
cluster parsing must read fields from both shapes. Multiple key aliases
|
||||
are accepted so snake_case SDK attributes and camelCase API/dict keys
|
||||
are both supported (e.g. ``allowed_cidrs`` / ``allowedCidrs``). ``None``
|
||||
items return ``default`` so nested lookups can be chained safely.
|
||||
Returns the first non-None match, otherwise ``default``.
|
||||
"""
|
||||
if item is None:
|
||||
return default
|
||||
if isinstance(item, dict):
|
||||
for key in keys:
|
||||
value = item.get(key)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
for key in keys:
|
||||
value = getattr(item, key, None)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _is_service_not_enabled(error) -> bool:
|
||||
"""Return True for the 403 SKE returns in a region that never enabled it.
|
||||
|
||||
Status alone cannot separate this from a missing IAM role -- both are
|
||||
403 -- so the body message is the only discriminator.
|
||||
"""
|
||||
if getattr(error, "status", None) != 403:
|
||||
return False
|
||||
body = getattr(error, "body", None)
|
||||
if body is None:
|
||||
return False
|
||||
if isinstance(body, bytes):
|
||||
body = body.decode("utf-8", errors="replace")
|
||||
message = ""
|
||||
if isinstance(body, str):
|
||||
try:
|
||||
message = json.loads(body).get("message", "")
|
||||
except (ValueError, AttributeError):
|
||||
message = body
|
||||
elif isinstance(body, dict):
|
||||
message = body.get("message", "")
|
||||
return "service not enabled" in str(message).lower()
|
||||
|
||||
def _handle_api_call(self, api_function, *args, **kwargs):
|
||||
"""
|
||||
Centralized API call handler with authentication error detection.
|
||||
|
||||
Args:
|
||||
api_function: The API function to call
|
||||
*args: Positional arguments to pass to the API function
|
||||
**kwargs: Keyword arguments to pass to the API function
|
||||
|
||||
Returns:
|
||||
The API response
|
||||
|
||||
Raises:
|
||||
StackITInvalidTokenError: If authentication fails (401)
|
||||
"""
|
||||
try:
|
||||
# Suppress StackIT SDK stderr messages during API calls
|
||||
with suppress_stderr():
|
||||
return api_function(*args, **kwargs)
|
||||
except Exception as e:
|
||||
# A region that never enabled SKE also answers 403; handing it to
|
||||
# handle_api_error would abort the scan as a credentials failure
|
||||
# instead of letting _fetch_all_regions skip the region.
|
||||
if self._is_service_not_enabled(e):
|
||||
raise
|
||||
# Use centralized error handler from provider
|
||||
self.provider.handle_api_error(e)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def _parse_access_scope(cls, cluster_data) -> Optional[str]:
|
||||
"""Return the control plane access scope of a cluster, if declared.
|
||||
|
||||
The scope lives at ``network.controlPlane.accessScope``. Every level of
|
||||
that chain is optional because the private (``SNA``) control plane is an
|
||||
opt-in preview feature, so most clusters omit it entirely. ``None`` is
|
||||
returned in that case and the caller falls back to the ACL extension.
|
||||
"""
|
||||
network = cls._get_field(cluster_data, "network")
|
||||
control_plane = cls._get_field(network, "control_plane", "controlPlane")
|
||||
access_scope = cls._get_field(control_plane, "access_scope", "accessScope")
|
||||
if access_scope is None:
|
||||
return None
|
||||
# ``AccessScope`` is a ``str`` Enum, whose ``str()`` renders as
|
||||
# "AccessScope.SNA" rather than the "SNA" wire value.
|
||||
return str(getattr(access_scope, "value", access_scope))
|
||||
|
||||
@classmethod
|
||||
def _parse_acl(cls, cluster_data) -> tuple[bool, list[str]]:
|
||||
"""Return the ``(enabled, allowed_cidrs)`` pair of the cluster ACL extension.
|
||||
|
||||
The ACL extension restricts which source CIDRs may reach the Kubernetes
|
||||
API and lives at ``extensions.acl``. When the extension is absent the
|
||||
API server accepts connections from any address, which is reported here
|
||||
as ``(False, [])``.
|
||||
"""
|
||||
extensions = cls._get_field(cluster_data, "extensions")
|
||||
acl = cls._get_field(extensions, "acl")
|
||||
enabled = bool(cls._get_field(acl, "enabled", default=False))
|
||||
allowed_cidrs = (
|
||||
cls._get_field(acl, "allowed_cidrs", "allowedCidrs", default=[]) or []
|
||||
)
|
||||
return enabled, [str(cidr) for cidr in allowed_cidrs]
|
||||
|
||||
def _list_clusters(self, client, region: str):
|
||||
"""
|
||||
List all SKE clusters in the StackIT project for a single region.
|
||||
|
||||
Populates ``self.clusters`` with :class:`Cluster` objects describing the
|
||||
control plane access scope and the ACL extension of each cluster.
|
||||
"""
|
||||
if not client:
|
||||
logger.warning(
|
||||
f"Cannot list SKE clusters in {region}: StackIT SKE client not available"
|
||||
)
|
||||
return
|
||||
|
||||
response = self._handle_api_call(
|
||||
client.list_clusters, project_id=self.project_id, region=region
|
||||
)
|
||||
|
||||
clusters_list = self._extract_items(response, "list_clusters")
|
||||
|
||||
for cluster_data in clusters_list:
|
||||
try:
|
||||
cluster_name = str(self._get_field(cluster_data, "name") or "")
|
||||
acl_enabled, allowed_cidrs = self._parse_acl(cluster_data)
|
||||
cluster = Cluster(
|
||||
# SKE addresses a cluster by its name; the API exposes no
|
||||
# separate identifier, so the name doubles as the id.
|
||||
id=cluster_name,
|
||||
name=cluster_name,
|
||||
project_id=self.project_id,
|
||||
region=region,
|
||||
access_scope=self._parse_access_scope(cluster_data),
|
||||
acl_enabled=acl_enabled,
|
||||
allowed_cidrs=allowed_cidrs,
|
||||
)
|
||||
self.clusters.append(cluster)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing SKE cluster: {e}")
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Successfully listed {len(clusters_list)} SKE clusters in {region}"
|
||||
)
|
||||
|
||||
|
||||
class Cluster(BaseModel):
|
||||
"""
|
||||
Represents a StackIT SKE Cluster.
|
||||
|
||||
Attributes:
|
||||
id: The unique identifier of the cluster (SKE uses the cluster name)
|
||||
name: The name of the cluster
|
||||
project_id: The StackIT project ID containing the cluster
|
||||
region: The region where the cluster runs
|
||||
access_scope: Control plane access scope ("PUBLIC"/"SNA"), None when unset
|
||||
acl_enabled: Whether the ACL extension restricting API access is enabled
|
||||
allowed_cidrs: Source CIDRs allowed to reach the Kubernetes API
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
project_id: str
|
||||
region: str
|
||||
access_scope: Optional[str] = None
|
||||
acl_enabled: bool = False
|
||||
allowed_cidrs: list[str] = []
|
||||
|
||||
def has_private_control_plane(self) -> bool:
|
||||
"""Check whether the control plane is confined to a STACKIT Network Area."""
|
||||
return self.access_scope == PRIVATE_ACCESS_SCOPE
|
||||
|
||||
def unrestricted_cidrs(self) -> list[str]:
|
||||
"""Return the allowed CIDRs that permit access from any source address."""
|
||||
return [cidr for cidr in self.allowed_cidrs if cidr in UNRESTRICTED_CIDRS]
|
||||
|
||||
def has_public_endpoint(self) -> bool:
|
||||
"""Check whether the Kubernetes API endpoint is reachable from the internet.
|
||||
|
||||
A cluster is publicly reachable when its control plane is not confined to
|
||||
a STACKIT Network Area and either the ACL extension is disabled (no source
|
||||
restriction at all) or the allowlist itself contains an unrestricted range.
|
||||
"""
|
||||
if self.has_private_control_plane():
|
||||
return False
|
||||
if not self.acl_enabled:
|
||||
return True
|
||||
return bool(self.unrestricted_cidrs())
|
||||
@@ -17,7 +17,6 @@ from stackit.core.configuration import Configuration
|
||||
from stackit.iaas import DefaultApi as IaasDefaultApi
|
||||
from stackit.objectstorage import DefaultApi as ObjectStorageDefaultApi
|
||||
from stackit.resourcemanager import DefaultApi as ResourceManagerDefaultApi
|
||||
from stackit.ske import DefaultApi as SkeDefaultApi
|
||||
|
||||
from prowler.config.config import (
|
||||
default_config_file_path,
|
||||
@@ -229,7 +228,6 @@ class StackitProvider(Provider):
|
||||
_SERVICE_API_CLASS = {
|
||||
"iaas": IaasDefaultApi,
|
||||
"objectstorage": ObjectStorageDefaultApi,
|
||||
"ske": SkeDefaultApi,
|
||||
}
|
||||
|
||||
def generate_regional_clients(self, service: str = "iaas") -> dict:
|
||||
@@ -559,11 +557,10 @@ class StackitProvider(Provider):
|
||||
"""
|
||||
Test connection to StackIT by validating credentials.
|
||||
|
||||
This method probes Resource Manager with a ``get_project`` call. A 403
|
||||
response is non-fatal because service-specific permissions are checked
|
||||
during discovery. Pass either the key file path or the inline key
|
||||
content; the SDK signs the RSA challenge and mints a short-lived access
|
||||
token internally.
|
||||
This method validates the service account credentials and project ID
|
||||
by making a Resource Manager ``get_project`` call. Pass either the
|
||||
key file path or the inline key content; the SDK signs the RSA
|
||||
challenge and mints a short-lived access token internally.
|
||||
|
||||
Args:
|
||||
project_id (str): StackIT project ID
|
||||
@@ -603,13 +600,6 @@ class StackitProvider(Provider):
|
||||
raise error
|
||||
return Connection(error=error)
|
||||
except Exception as test_error:
|
||||
if getattr(test_error, "status", None) == 403:
|
||||
logger.warning(
|
||||
"StackIT test_connection: Resource Manager access could not be "
|
||||
"verified (403). Service permissions will be checked during "
|
||||
"discovery."
|
||||
)
|
||||
return Connection(is_connected=True)
|
||||
try:
|
||||
StackitProvider.handle_api_error(test_error)
|
||||
except StackITInvalidTokenError as auth_error:
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
"eu01",
|
||||
"eu02"
|
||||
]
|
||||
},
|
||||
"ske": {
|
||||
"regions": [
|
||||
"eu01",
|
||||
"eu02"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ dependencies = [
|
||||
"stackit-iaas==1.4.0",
|
||||
"stackit-objectstorage==1.4.0",
|
||||
"stackit-resourcemanager==0.8.0",
|
||||
"stackit-ske==1.12.0",
|
||||
"tabulate==0.9.0",
|
||||
"tzlocal==5.3.1",
|
||||
"uuid6==2024.7.10",
|
||||
@@ -133,8 +132,7 @@ dependencies = [
|
||||
"huaweicloudsdkobs==3.1.204",
|
||||
"huaweicloudsdkrds==3.1.204",
|
||||
"huaweicloudsdkvpc==3.1.204",
|
||||
"huaweicloudsdkwaf==3.1.204",
|
||||
"zstandard==0.25.0"
|
||||
"huaweicloudsdkwaf==3.1.204"
|
||||
]
|
||||
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
|
||||
license = "Apache-2.0"
|
||||
@@ -142,7 +140,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
version = "5.40.0"
|
||||
version = "5.39.2"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -1,633 +0,0 @@
|
||||
from unittest import mock
|
||||
|
||||
import botocore
|
||||
from botocore.exceptions import ClientError
|
||||
from moto import mock_aws
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
AWS_REGION_US_WEST_2,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
AGENT_A_ID = "test-agent-a"
|
||||
AGENT_A_NAME = "agent-alpha"
|
||||
AGENT_A_ARN = (
|
||||
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_A_ID}"
|
||||
)
|
||||
AGENT_B_ID = "test-agent-b"
|
||||
AGENT_B_NAME = "agent-bravo"
|
||||
AGENT_B_ARN = (
|
||||
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_B_ID}"
|
||||
)
|
||||
AGENT_C_ID = "test-agent-c"
|
||||
AGENT_C_NAME = "agent-charlie"
|
||||
|
||||
SHARED_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/SharedAgentRole"
|
||||
ROLE_A_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentAlphaRole"
|
||||
ROLE_B_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentBravoRole"
|
||||
|
||||
# Operations the BedrockAgent constructor calls that these tests do not exercise.
|
||||
_UNUSED_OPERATIONS = (
|
||||
"ListPrompts",
|
||||
"GetPrompt",
|
||||
"ListTagsForResource",
|
||||
"ListKnowledgeBases",
|
||||
"ListDataSources",
|
||||
"GetDataSource",
|
||||
)
|
||||
|
||||
# These scenarios describe agents with no alias, so no version is deployed and
|
||||
# the draft role is the only one in play. ListAgentAliases is stubbed empty
|
||||
# rather than left to moto, which does not implement it: an unstubbed call would
|
||||
# leave the version inventory unread and correctly downgrade every PASS to
|
||||
# MANUAL, masking what these tests are actually asserting.
|
||||
_NO_ALIASES = {"agentAliasSummaries": []}
|
||||
|
||||
|
||||
def _agent_mock(agents, fail_get_for=()):
|
||||
"""Build a _make_api_call replacement returning the given agents.
|
||||
|
||||
Args:
|
||||
agents: list of (agentId, agentName, roleArn) tuples.
|
||||
fail_get_for: agent ids whose GetAgent call must raise.
|
||||
"""
|
||||
|
||||
def _mock(self, operation_name, kwarg):
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListAgentAliases":
|
||||
return _NO_ALIASES
|
||||
if operation_name == "ListAgents":
|
||||
return {
|
||||
"agentSummaries": [
|
||||
{
|
||||
"agentId": agent_id,
|
||||
"agentName": agent_name,
|
||||
"agentStatus": "PREPARED",
|
||||
}
|
||||
for agent_id, agent_name, _ in agents
|
||||
]
|
||||
}
|
||||
if operation_name == "GetAgent":
|
||||
agent_id = kwarg["agentId"]
|
||||
if agent_id in fail_get_for:
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
# GetAgent nests its payload under a top-level agent key.
|
||||
for candidate_id, agent_name, role_arn in agents:
|
||||
if candidate_id == agent_id:
|
||||
agent = {
|
||||
"agentId": candidate_id,
|
||||
"agentName": agent_name,
|
||||
"agentStatus": "PREPARED",
|
||||
}
|
||||
if role_arn is not None:
|
||||
agent["agentResourceRoleArn"] = role_arn
|
||||
return {"agent": agent}
|
||||
return {"agent": {}}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
_mock_dedicated_roles = _agent_mock(
|
||||
[
|
||||
(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN),
|
||||
(AGENT_B_ID, AGENT_B_NAME, ROLE_B_ARN),
|
||||
]
|
||||
)
|
||||
_mock_shared_role = _agent_mock(
|
||||
[
|
||||
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
|
||||
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
|
||||
]
|
||||
)
|
||||
_mock_shared_role_three_agents = _agent_mock(
|
||||
[
|
||||
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
|
||||
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
|
||||
(AGENT_C_ID, AGENT_C_NAME, SHARED_ROLE_ARN),
|
||||
]
|
||||
)
|
||||
_mock_single_agent = _agent_mock([(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN)])
|
||||
_mock_role_missing = _agent_mock([(AGENT_A_ID, AGENT_A_NAME, None)])
|
||||
_mock_get_agent_fails = _agent_mock(
|
||||
[(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN)], fail_get_for=(AGENT_A_ID,)
|
||||
)
|
||||
# One agent's role is unreadable, so it must not count toward the other's share.
|
||||
_mock_one_unreadable_one_readable = _agent_mock(
|
||||
[
|
||||
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
|
||||
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
|
||||
],
|
||||
fail_get_for=(AGENT_B_ID,),
|
||||
)
|
||||
|
||||
|
||||
def _mock_empty(self, operation_name, kwarg):
|
||||
"""No agents at all."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListAgentAliases":
|
||||
return _NO_ALIASES
|
||||
if operation_name == "ListAgents":
|
||||
return {"agentSummaries": []}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_unsupported_region(self, operation_name, kwarg):
|
||||
"""The API is not available in the audited region."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListAgentAliases":
|
||||
return _NO_ALIASES
|
||||
if operation_name == "ListAgents":
|
||||
raise ClientError(
|
||||
{
|
||||
"Error": {
|
||||
"Code": "ValidationException",
|
||||
"Message": "Bedrock Agent is not supported in this region.",
|
||||
}
|
||||
},
|
||||
operation_name,
|
||||
)
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _inventory_mock(agents):
|
||||
"""Build a stub for one exhaustive-matrix inventory.
|
||||
|
||||
Args:
|
||||
agents: list of (name, roleArn, retrieved) tuples. retrieved=False makes
|
||||
GetAgent raise for that agent.
|
||||
"""
|
||||
rows = [(name, name, role) for name, role, _ in agents]
|
||||
fail = tuple(name for name, _, retrieved in agents if not retrieved)
|
||||
return _agent_mock(rows, fail_get_for=fail)
|
||||
|
||||
|
||||
def _mock_list_agents_denied(self, operation_name, kwarg):
|
||||
"""ListAgents is denied, so the region's agents are unknown."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListAgentAliases":
|
||||
return _NO_ALIASES
|
||||
if operation_name == "ListAgents":
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_dedicated_role_with_partial_inventory(self, operation_name, kwarg):
|
||||
"""us-east-1 lists one agent; us-west-2's ListAgents is denied.
|
||||
|
||||
The listed agent's role is used by no other KNOWN agent, but an unlisted
|
||||
Region could hold one sharing it, so PASS must not be asserted.
|
||||
"""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListAgentAliases":
|
||||
return _NO_ALIASES
|
||||
region = self.meta.region_name
|
||||
if operation_name == "ListAgents":
|
||||
if region == AWS_REGION_US_EAST_1:
|
||||
return {
|
||||
"agentSummaries": [
|
||||
{
|
||||
"agentId": AGENT_A_ID,
|
||||
"agentName": AGENT_A_NAME,
|
||||
"agentStatus": "PREPARED",
|
||||
}
|
||||
]
|
||||
}
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
if operation_name == "GetAgent":
|
||||
return {
|
||||
"agent": {
|
||||
"agentId": AGENT_A_ID,
|
||||
"agentName": AGENT_A_NAME,
|
||||
"agentStatus": "PREPARED",
|
||||
"agentResourceRoleArn": ROLE_A_ARN,
|
||||
}
|
||||
}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
class Test_bedrock_agent_role_not_shared_across_agents:
|
||||
"""Unit tests for the bedrock_agent_role_not_shared_across_agents check."""
|
||||
|
||||
def _run(self):
|
||||
"""Import the service + check under the active mocks and execute."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents.bedrock_agent_client",
|
||||
new=BedrockAgent(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents import (
|
||||
bedrock_agent_role_not_shared_across_agents,
|
||||
)
|
||||
|
||||
return bedrock_agent_role_not_shared_across_agents().execute()
|
||||
|
||||
def _run_multi_region(self):
|
||||
"""Same as _run but with two Regions in scope."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider(
|
||||
[AWS_REGION_US_EAST_1, AWS_REGION_US_WEST_2]
|
||||
)
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents.bedrock_agent_client",
|
||||
new=BedrockAgent(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents import (
|
||||
bedrock_agent_role_not_shared_across_agents,
|
||||
)
|
||||
|
||||
return bedrock_agent_role_not_shared_across_agents().execute()
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty)
|
||||
@mock_aws
|
||||
def test_no_resources(self):
|
||||
"""No resources means no findings, not a spurious FAIL."""
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region
|
||||
)
|
||||
@mock_aws
|
||||
def test_region_not_supported(self):
|
||||
"""A ValidationException from the region must not raise; it yields no findings."""
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_single_agent)
|
||||
@mock_aws
|
||||
def test_single_agent_passes(self):
|
||||
"""The only agent in the account cannot be sharing its role."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].resource_id == AGENT_A_ID
|
||||
assert result[0].resource_arn == AGENT_A_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Bedrock Agent {AGENT_A_NAME} has a dedicated execution role in region {AWS_REGION_US_EAST_1}."
|
||||
)
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_dedicated_roles)
|
||||
@mock_aws
|
||||
def test_distinct_roles_pass(self):
|
||||
"""Two agents with distinct roles are both compliant."""
|
||||
result = self._run()
|
||||
assert len(result) == 2
|
||||
assert {report.status for report in result} == {"PASS"}
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_shared_role)
|
||||
@mock_aws
|
||||
def test_shared_role_fails_for_both_agents(self):
|
||||
"""A role used by two agents fails for each of them, naming the other."""
|
||||
result = self._run()
|
||||
assert len(result) == 2
|
||||
assert {report.status for report in result} == {"FAIL"}
|
||||
by_id = {report.resource_id: report for report in result}
|
||||
assert SHARED_ROLE_ARN in by_id[AGENT_A_ID].status_extended
|
||||
assert AGENT_B_NAME in by_id[AGENT_A_ID].status_extended
|
||||
assert AGENT_A_NAME in by_id[AGENT_B_ID].status_extended
|
||||
assert "cannot attribute an action" in by_id[AGENT_A_ID].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_shared_role_three_agents
|
||||
)
|
||||
@mock_aws
|
||||
def test_shared_role_names_others_sorted(self):
|
||||
"""The other sharing agents are listed in sorted order for determinism."""
|
||||
result = self._run()
|
||||
assert len(result) == 3
|
||||
assert {report.status for report in result} == {"FAIL"}
|
||||
by_id = {report.resource_id: report for report in result}
|
||||
assert (
|
||||
f"with {AGENT_B_NAME}, {AGENT_C_NAME} in region"
|
||||
in by_id[AGENT_A_ID].status_extended
|
||||
)
|
||||
assert (
|
||||
f"with {AGENT_A_NAME}, {AGENT_C_NAME} in region"
|
||||
in by_id[AGENT_B_ID].status_extended
|
||||
)
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_role_missing)
|
||||
@mock_aws
|
||||
def test_role_absent_is_manual_not_pass(self):
|
||||
"""An agent whose GetAgent returned no role must not be reported as compliant."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "could not be retrieved" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_get_agent_fails)
|
||||
@mock_aws
|
||||
def test_get_agent_failure_is_manual_not_pass(self):
|
||||
"""A failed GetAgent must not be reported as compliant."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "could not be retrieved" in result[0].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_mock_one_unreadable_one_readable,
|
||||
)
|
||||
@mock_aws
|
||||
def test_unreadable_agent_does_not_inflate_share_count(self):
|
||||
"""An excluded agent must neither manufacture a FAIL nor allow a PASS.
|
||||
|
||||
The unreadable agent is kept out of the share index, so the readable agent
|
||||
is not reported as sharing. But its role is unknown and could be the same
|
||||
one, so the readable agent cannot be asserted dedicated either: both are
|
||||
MANUAL, and neither is FAIL.
|
||||
"""
|
||||
result = self._run()
|
||||
assert len(result) == 2
|
||||
by_id = {report.resource_id: report for report in result}
|
||||
assert by_id[AGENT_B_ID].status == "MANUAL"
|
||||
assert by_id[AGENT_A_ID].status == "MANUAL"
|
||||
assert "could not be read" in by_id[AGENT_A_ID].status_extended
|
||||
# The entry names what could not be read, so the sentence stays
|
||||
# grammatical for a role, a version set, or a Region alike.
|
||||
assert (
|
||||
f"the execution role of {AGENT_B_NAME}" in by_id[AGENT_A_ID].status_extended
|
||||
)
|
||||
assert {report.status for report in result} == {"MANUAL"}
|
||||
|
||||
def test_every_inventory_shape_resolves_correctly(self):
|
||||
"""Exhaust the decision space instead of sampling it.
|
||||
|
||||
Bedrock Agents cannot be created in every account (the service refuses new
|
||||
agents for accounts without prior usage), so this check's behaviour is
|
||||
pinned by enumerating every inventory of up to three agents over the cross
|
||||
product of {role A, role B, no role} x {readable, unreadable}, and
|
||||
asserting the verdict for each agent. A role counts as shared only when two
|
||||
or more READABLE agents hold it.
|
||||
|
||||
Each case is driven through the real BedrockAgent service so a renamed
|
||||
service attribute breaks the test rather than passing silently.
|
||||
"""
|
||||
from itertools import product
|
||||
|
||||
states = [
|
||||
(ROLE_A_ARN, True),
|
||||
(ROLE_B_ARN, True),
|
||||
(None, True),
|
||||
(ROLE_A_ARN, False),
|
||||
]
|
||||
|
||||
checked = 0
|
||||
for size in (1, 2, 3):
|
||||
for combo in product(states, repeat=size):
|
||||
agents = [
|
||||
(f"agent-{index}", role, retrieved)
|
||||
for index, (role, retrieved) in enumerate(combo)
|
||||
]
|
||||
|
||||
readable_per_role = {}
|
||||
for _, role, retrieved in agents:
|
||||
if retrieved and role:
|
||||
readable_per_role[role] = readable_per_role.get(role, 0) + 1
|
||||
# Any agent whose own role could not be read leaves the picture
|
||||
# incomplete, so no other agent can be asserted dedicated. A role
|
||||
# already seen twice is shared regardless.
|
||||
any_unresolved = any(
|
||||
not retrieved or not role for _, role, retrieved in agents
|
||||
)
|
||||
expected = sorted(
|
||||
(
|
||||
"MANUAL"
|
||||
if not retrieved or not role
|
||||
else (
|
||||
"FAIL"
|
||||
if readable_per_role[role] >= 2
|
||||
else ("MANUAL" if any_unresolved else "PASS")
|
||||
)
|
||||
)
|
||||
for _, role, retrieved in agents
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_inventory_mock(agents),
|
||||
):
|
||||
with mock_aws():
|
||||
result = self._run()
|
||||
|
||||
assert len(result) == size, combo
|
||||
assert sorted(report.status for report in result) == expected, combo
|
||||
assert all(
|
||||
report.status_extended.endswith(".") for report in result
|
||||
), combo
|
||||
checked += 1
|
||||
|
||||
# 4 + 16 + 64 inventories.
|
||||
assert checked == 84
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_list_agents_denied
|
||||
)
|
||||
@mock_aws
|
||||
def test_list_agents_denied_is_manual_not_silence(self):
|
||||
"""A denied ListAgents must report MANUAL for the region, not vanish."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].resource_id == "agent/unknown"
|
||||
assert "could not be listed" in result[0].status_extended
|
||||
assert "AccessDeniedException" in result[0].status_extended
|
||||
assert result[0].status_extended.endswith(".")
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_mock_dedicated_role_with_partial_inventory,
|
||||
)
|
||||
@mock_aws
|
||||
def test_dedicated_role_is_manual_when_inventory_incomplete(self):
|
||||
"""A seemingly dedicated role cannot be asserted from a partial inventory.
|
||||
|
||||
One Region lists an agent whose role no other known agent uses, while
|
||||
another Region's ListAgents fails. An unlisted Region could hold an agent
|
||||
sharing that role, so the verdict is MANUAL rather than PASS.
|
||||
"""
|
||||
result = self._run_multi_region()
|
||||
by_status = {}
|
||||
for report in result:
|
||||
by_status.setdefault(report.status, []).append(report)
|
||||
assert "PASS" not in by_status, [r.status_extended for r in result]
|
||||
assert len(by_status["MANUAL"]) == 2
|
||||
agent_report = [
|
||||
r for r in by_status["MANUAL"] if r.resource_id != "agent/unknown"
|
||||
]
|
||||
assert len(agent_report) == 1
|
||||
assert "agents in region" in agent_report[0].status_extended
|
||||
assert all(r.status_extended.endswith(".") for r in result)
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_agent_mock(
|
||||
[
|
||||
(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN),
|
||||
(AGENT_B_ID, AGENT_B_NAME, ROLE_B_ARN),
|
||||
],
|
||||
fail_get_for=(AGENT_B_ID,),
|
||||
),
|
||||
)
|
||||
@mock_aws
|
||||
def test_unresolved_role_blocks_pass_for_a_distinct_role(self):
|
||||
"""One agent's unreadable role prevents asserting another's dedication.
|
||||
|
||||
Agent A holds a role no other *readable* agent uses, so the old logic
|
||||
returned PASS. Agent B's role could not be retrieved and may be the same
|
||||
one, so PASS would be an assertion the data does not support.
|
||||
"""
|
||||
result = self._run()
|
||||
assert len(result) == 2
|
||||
by_id = {report.resource_id: report for report in result}
|
||||
assert by_id[AGENT_B_ID].status == "MANUAL"
|
||||
assert by_id[AGENT_A_ID].status == "MANUAL"
|
||||
assert AGENT_B_NAME in by_id[AGENT_A_ID].status_extended
|
||||
assert "PASS" not in {report.status for report in result}
|
||||
|
||||
|
||||
class Test_scoped_scan_still_sees_the_sharing:
|
||||
"""A --resource-arn scoped scan must not turn a shared role into a PASS.
|
||||
|
||||
Collectors apply is_resource_filtered at COLLECTION time, so
|
||||
bedrock_agent_client.agents holds only the agents the operator selected.
|
||||
Whether a role is shared is a property of every agent that holds it, so an
|
||||
index built from the filtered set cannot see the agent that proves the
|
||||
violation -- and an operator filter is not a scan error, so nothing marks the
|
||||
inventory incomplete either. The check therefore aggregates over all_agents
|
||||
and filters only when emitting findings.
|
||||
"""
|
||||
|
||||
def _run_scoped(self, audit_resources):
|
||||
"""Execute the check with a scan scoped to the given resource ARNs."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
aws_provider._audit_resources = audit_resources
|
||||
with mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
):
|
||||
service = BedrockAgent(aws_provider)
|
||||
with mock.patch(
|
||||
"prowler.providers.aws.services.bedrock."
|
||||
"bedrock_agent_role_not_shared_across_agents."
|
||||
"bedrock_agent_role_not_shared_across_agents.bedrock_agent_client",
|
||||
new=service,
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents import (
|
||||
bedrock_agent_role_not_shared_across_agents,
|
||||
)
|
||||
|
||||
return service, bedrock_agent_role_not_shared_across_agents().execute()
|
||||
|
||||
@mock_aws
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_agent_mock(
|
||||
[
|
||||
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
|
||||
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
|
||||
]
|
||||
),
|
||||
)
|
||||
def test_selecting_one_of_two_sharing_agents_still_fails(self):
|
||||
"""The unselected agent is what proves the role is shared.
|
||||
|
||||
Only agent A is in scope, so exactly one finding is emitted -- but agent
|
||||
B, filtered out of the report, still holds the same role, so agent A's
|
||||
role is not dedicated. Reporting PASS here was the reproducible false
|
||||
PASS a scoped scan produced.
|
||||
"""
|
||||
service, result = self._run_scoped([AGENT_A_ARN])
|
||||
|
||||
# The report set is narrowed; the role index is not.
|
||||
assert list(service.agents) == [AGENT_A_ARN]
|
||||
assert sorted(service.all_agents) == sorted([AGENT_A_ARN, AGENT_B_ARN])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == AGENT_A_ID
|
||||
assert result[0].resource_arn == AGENT_A_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].status == "FAIL"
|
||||
assert SHARED_ROLE_ARN in result[0].status_extended
|
||||
# The out-of-scope agent is still named, because it is the evidence.
|
||||
assert AGENT_B_NAME in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_agent_mock(
|
||||
[
|
||||
(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN),
|
||||
(AGENT_B_ID, AGENT_B_NAME, ROLE_B_ARN),
|
||||
]
|
||||
),
|
||||
)
|
||||
def test_scoped_scan_on_a_genuinely_dedicated_role_passes(self):
|
||||
"""Completeness must not manufacture a FAIL either.
|
||||
|
||||
Aggregating over the whole account is only correct if a genuinely
|
||||
dedicated role still passes when the scan is scoped.
|
||||
"""
|
||||
service, result = self._run_scoped([AGENT_A_ARN])
|
||||
|
||||
assert list(service.agents) == [AGENT_A_ARN]
|
||||
assert len(service.all_agents) == 2
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == AGENT_A_ID
|
||||
assert result[0].status == "PASS"
|
||||
assert "has a dedicated execution role" in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_agent_mock(
|
||||
[
|
||||
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
|
||||
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
|
||||
]
|
||||
),
|
||||
)
|
||||
def test_unscoped_scan_is_unchanged(self):
|
||||
"""With no filter, every agent is both aggregated and reported."""
|
||||
service, result = self._run_scoped(None)
|
||||
|
||||
assert sorted(service.agents) == sorted(service.all_agents)
|
||||
assert len(result) == 2
|
||||
assert {report.status for report in result} == {"FAIL"}
|
||||
@@ -1,556 +0,0 @@
|
||||
"""Tests that a role shared through a deployed agent version is still reported.
|
||||
|
||||
GetAgent returns only the working draft. An agent version is an immutable
|
||||
snapshot that keeps the role it was cut with, and an alias routes invocations at
|
||||
a specific version, so two agents whose drafts hold distinct roles can still be
|
||||
invoking one shared role in production. Judging the draft alone reports that as
|
||||
compliant.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from moto import mock_aws
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
AGENT_A_ID = "test-agent-a"
|
||||
AGENT_A_NAME = "agent-alpha"
|
||||
AGENT_B_ID = "test-agent-b"
|
||||
AGENT_B_NAME = "agent-bravo"
|
||||
|
||||
DRAFT_A_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentAlphaDraftRole"
|
||||
DRAFT_B_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentBravoDraftRole"
|
||||
SHARED_VERSION_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/SharedVersionRole"
|
||||
DEDICATED_VERSION_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AlphaVersionRole"
|
||||
|
||||
# Collectors the BedrockAgent constructor runs that these tests do not exercise.
|
||||
_UNUSED_OPERATIONS = (
|
||||
"ListPrompts",
|
||||
"GetPrompt",
|
||||
"ListTagsForResource",
|
||||
"ListKnowledgeBases",
|
||||
"ListDataSources",
|
||||
"GetDataSource",
|
||||
)
|
||||
|
||||
DRAFT_ROLES = {AGENT_A_ID: DRAFT_A_ARN, AGENT_B_ID: DRAFT_B_ARN}
|
||||
|
||||
|
||||
def _mock(
|
||||
version_roles,
|
||||
routed_version="3",
|
||||
fail_list_aliases=(),
|
||||
fail_get_version=(),
|
||||
route_draft=False,
|
||||
no_aliases=(),
|
||||
alias_status="PREPARED",
|
||||
alias_invocation_state=None,
|
||||
aliases=None,
|
||||
draft_roles=DRAFT_ROLES,
|
||||
):
|
||||
"""Build a _make_api_call replacement for a two-agent account.
|
||||
|
||||
Args:
|
||||
version_roles: agent id -> role ARN its deployed version was cut with.
|
||||
routed_version: the version each alias routes to.
|
||||
fail_list_aliases: agent ids whose ListAgentAliases must raise.
|
||||
fail_get_version: agent ids whose GetAgentVersion must raise.
|
||||
route_draft: route the alias at DRAFT instead of a numbered version.
|
||||
no_aliases: agent ids that have no alias at all.
|
||||
alias_status: agentAliasStatus each alias reports.
|
||||
alias_invocation_state: aliasInvocationState each alias reports; omitted
|
||||
from the response entirely when None, which is what the API does for
|
||||
an alias never set to reject.
|
||||
aliases: agentAliasSummaries to return instead of building one alias.
|
||||
draft_roles: agent id -> role ARN returned by GetAgent.
|
||||
"""
|
||||
|
||||
def _call(self, operation_name, kwarg):
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListAgents":
|
||||
return {
|
||||
"agentSummaries": [
|
||||
{
|
||||
"agentId": AGENT_A_ID,
|
||||
"agentName": AGENT_A_NAME,
|
||||
"agentStatus": "PREPARED",
|
||||
},
|
||||
{
|
||||
"agentId": AGENT_B_ID,
|
||||
"agentName": AGENT_B_NAME,
|
||||
"agentStatus": "PREPARED",
|
||||
},
|
||||
]
|
||||
}
|
||||
if operation_name == "GetAgent":
|
||||
agent_id = kwarg["agentId"]
|
||||
return {
|
||||
"agent": {
|
||||
"agentId": agent_id,
|
||||
"agentName": agent_id,
|
||||
"agentStatus": "PREPARED",
|
||||
"agentResourceRoleArn": draft_roles[agent_id],
|
||||
}
|
||||
}
|
||||
if operation_name == "ListAgentAliases":
|
||||
agent_id = kwarg["agentId"]
|
||||
if agent_id in fail_list_aliases:
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
if aliases is not None:
|
||||
return {"agentAliasSummaries": aliases}
|
||||
if agent_id in no_aliases:
|
||||
return {"agentAliasSummaries": []}
|
||||
alias = {
|
||||
"agentAliasId": "alias-1",
|
||||
"agentAliasName": "production",
|
||||
"agentAliasStatus": alias_status,
|
||||
"routingConfiguration": [
|
||||
{"agentVersion": "DRAFT" if route_draft else routed_version}
|
||||
],
|
||||
}
|
||||
if alias_invocation_state is not None:
|
||||
alias["aliasInvocationState"] = alias_invocation_state
|
||||
return {"agentAliasSummaries": [alias]}
|
||||
if operation_name == "GetAgentVersion":
|
||||
agent_id = kwarg["agentId"]
|
||||
if agent_id in fail_get_version:
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
return {
|
||||
"agentVersion": {
|
||||
"agentId": agent_id,
|
||||
"agentName": agent_id,
|
||||
"version": kwarg["agentVersion"],
|
||||
"agentStatus": "PREPARED",
|
||||
"agentResourceRoleArn": version_roles[agent_id],
|
||||
}
|
||||
}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
return _call
|
||||
|
||||
|
||||
def _run(stub):
|
||||
"""Import the service and check under the stub and execute."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
check_name = "bedrock_agent_role_not_shared_across_agents"
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
with (
|
||||
mock.patch("botocore.client.BaseClient._make_api_call", new=stub),
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
):
|
||||
service = BedrockAgent(aws_provider)
|
||||
with mock.patch(
|
||||
f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}.bedrock_agent_client",
|
||||
new=service,
|
||||
):
|
||||
module = __import__(
|
||||
f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}",
|
||||
fromlist=[check_name],
|
||||
)
|
||||
return service, getattr(module, check_name)().execute()
|
||||
|
||||
|
||||
class Test_agent_version_roles:
|
||||
"""Tests for sharing judged across the draft and every routed version."""
|
||||
|
||||
@mock_aws
|
||||
def test_shared_version_role_fails_despite_distinct_drafts(self):
|
||||
"""Distinct drafts do not make two agents dedicated.
|
||||
|
||||
Both agents' aliases route at a version cut with one shared role, so
|
||||
both are invoking it in production. Judging the draft alone would call
|
||||
this compliant.
|
||||
"""
|
||||
service, results = _run(
|
||||
_mock({AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN})
|
||||
)
|
||||
|
||||
for agent in service.agents.values():
|
||||
assert agent.versions_listed is True
|
||||
assert agent.version_role_arns == {"3": SHARED_VERSION_ARN}
|
||||
|
||||
assert len(results) == 2
|
||||
assert {report.status for report in results} == {"FAIL"}
|
||||
for report in results:
|
||||
assert SHARED_VERSION_ARN in report.status_extended
|
||||
# The message names the version, so the reader knows the draft is
|
||||
# not where the sharing lives.
|
||||
assert "through deployed version 3" in report.status_extended
|
||||
assert report.status_extended.endswith(".")
|
||||
|
||||
@mock_aws
|
||||
def test_distinct_version_roles_pass(self):
|
||||
"""Distinct roles on both the draft and the deployed version comply."""
|
||||
_, results = _run(
|
||||
_mock(
|
||||
{
|
||||
AGENT_A_ID: DEDICATED_VERSION_ARN,
|
||||
AGENT_B_ID: f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/BravoVersionRole",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert {report.status for report in results} == {"PASS"}
|
||||
|
||||
@mock_aws
|
||||
def test_draft_sharing_still_names_no_version(self):
|
||||
"""Sharing on the draft is reported without a version attribution."""
|
||||
stub = _mock(
|
||||
{
|
||||
AGENT_A_ID: DEDICATED_VERSION_ARN,
|
||||
AGENT_B_ID: f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/BravoVersionRole",
|
||||
}
|
||||
)
|
||||
|
||||
def _shared_draft(self, operation_name, kwarg):
|
||||
if operation_name == "GetAgent":
|
||||
return {
|
||||
"agent": {
|
||||
"agentId": kwarg["agentId"],
|
||||
"agentName": kwarg["agentId"],
|
||||
"agentStatus": "PREPARED",
|
||||
"agentResourceRoleArn": DRAFT_A_ARN,
|
||||
}
|
||||
}
|
||||
return stub(self, operation_name, kwarg)
|
||||
|
||||
_, results = _run(_shared_draft)
|
||||
|
||||
assert len(results) == 2
|
||||
assert {report.status for report in results} == {"FAIL"}
|
||||
for report in results:
|
||||
assert DRAFT_A_ARN in report.status_extended
|
||||
assert "through deployed version" not in report.status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_alias_routing_at_draft_needs_no_version_call(self):
|
||||
"""DRAFT resolves to the role GetAgent already returned."""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
|
||||
route_draft=True,
|
||||
)
|
||||
)
|
||||
|
||||
for agent in service.agents.values():
|
||||
assert agent.versions_listed is True
|
||||
assert agent.version_role_arns == {}
|
||||
|
||||
assert len(results) == 2
|
||||
assert {report.status for report in results} == {"PASS"}
|
||||
|
||||
@mock_aws
|
||||
def test_agent_without_an_alias_deploys_no_version(self):
|
||||
"""No alias means no version is reachable, so the draft is the verdict."""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
|
||||
no_aliases=(AGENT_A_ID, AGENT_B_ID),
|
||||
)
|
||||
)
|
||||
|
||||
for agent in service.agents.values():
|
||||
assert agent.versions_listed is True
|
||||
assert agent.version_role_arns == {}
|
||||
|
||||
assert {report.status for report in results} == {"PASS"}
|
||||
|
||||
@mock_aws
|
||||
def test_unlistable_aliases_block_pass(self):
|
||||
"""An unread version inventory may hold the same role, so PASS is unsafe."""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN},
|
||||
fail_list_aliases=(AGENT_B_ID,),
|
||||
)
|
||||
)
|
||||
|
||||
agent_b = next(a for a in service.agents.values() if a.id == AGENT_B_ID)
|
||||
assert agent_b.versions_listed is False
|
||||
assert agent_b.versions_error == "AccessDeniedException"
|
||||
|
||||
assert "PASS" not in {report.status for report in results}
|
||||
assert {report.status for report in results} == {"MANUAL"}
|
||||
assert any(
|
||||
"deployed versions of" in report.status_extended for report in results
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_unreadable_version_blocks_pass(self):
|
||||
"""A failed GetAgentVersion leaves that version's role unknown."""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN},
|
||||
fail_get_version=(AGENT_A_ID,),
|
||||
)
|
||||
)
|
||||
|
||||
agent_a = next(a for a in service.agents.values() if a.id == AGENT_A_ID)
|
||||
assert agent_a.versions_listed is False
|
||||
|
||||
assert "PASS" not in {report.status for report in results}
|
||||
|
||||
@mock_aws
|
||||
def test_shared_version_role_outranks_an_incomplete_inventory(self):
|
||||
"""A role seen on two agents is shared whatever else is missing.
|
||||
|
||||
Both agents route at two versions and the second version is unreadable,
|
||||
so the inventory is incomplete and the read half already shows the role
|
||||
on both. A partial answer must not downgrade a definite finding, so FAIL
|
||||
stands rather than MANUAL.
|
||||
"""
|
||||
|
||||
def _two_versions_one_unreadable(self, operation_name, kwarg):
|
||||
if operation_name == "ListAgentAliases":
|
||||
return {
|
||||
"agentAliasSummaries": [
|
||||
{
|
||||
"agentAliasId": "alias-1",
|
||||
"agentAliasName": "production",
|
||||
"agentAliasStatus": "PREPARED",
|
||||
"routingConfiguration": [
|
||||
{"agentVersion": "2"},
|
||||
{"agentVersion": "3"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
if operation_name == "GetAgentVersion" and kwarg["agentVersion"] == "3":
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
return _mock(
|
||||
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}
|
||||
)(self, operation_name, kwarg)
|
||||
|
||||
service, results = _run(_two_versions_one_unreadable)
|
||||
|
||||
for agent in service.agents.values():
|
||||
assert agent.versions_listed is False, "the inventory must be incomplete"
|
||||
assert agent.version_role_arns == {"2": SHARED_VERSION_ARN}
|
||||
|
||||
assert {report.status for report in results} == {"FAIL"}
|
||||
for report in results:
|
||||
assert "through deployed version 2" in report.status_extended
|
||||
|
||||
|
||||
class Test_alias_must_be_invocable:
|
||||
"""A version only an unreachable alias routes to is not live exposure.
|
||||
|
||||
Widening the audit from the working draft to every routed version closes a
|
||||
false PASS and opens the symmetric false FAIL: an alias that cannot invoke
|
||||
the version it points at contributes no exposure, so the role on that
|
||||
version must not count as shared. Both state fields come from
|
||||
ListAgentAliases -- `aliasInvocationState` is ACCEPT_INVOCATIONS |
|
||||
REJECT_INVOCATIONS, `agentAliasStatus` is CREATING | PREPARED | FAILED |
|
||||
UPDATING | DELETING | DISSOCIATED.
|
||||
"""
|
||||
|
||||
@mock_aws
|
||||
def test_reject_invocations_alias_does_not_share_its_version_role(self):
|
||||
"""An alias set to REJECT_INVOCATIONS cannot invoke the routed version.
|
||||
|
||||
Both agents' aliases route at a version cut with one shared role, so
|
||||
judging routing alone reports FAIL -- but neither alias will accept an
|
||||
invocation, so nothing is running under that role.
|
||||
"""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
|
||||
alias_invocation_state="REJECT_INVOCATIONS",
|
||||
)
|
||||
)
|
||||
|
||||
for agent in service.all_agents.values():
|
||||
assert agent.versions_listed is True, "the aliases WERE listed"
|
||||
assert agent.version_role_arns == {}, "a rejecting alias routes nothing"
|
||||
|
||||
assert len(results) == 2
|
||||
assert {report.status for report in results} == {"PASS"}
|
||||
for report in results:
|
||||
assert "has a dedicated execution role" in report.status_extended
|
||||
|
||||
@pytest.mark.parametrize("alias_status", ["FAILED", "DELETING", "DISSOCIATED"])
|
||||
@mock_aws
|
||||
def test_terminal_alias_status_does_not_share_its_version_role(self, alias_status):
|
||||
"""A failed, deleting or dissociated alias routes no live invocation."""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
|
||||
alias_status=alias_status,
|
||||
)
|
||||
)
|
||||
|
||||
for agent in service.all_agents.values():
|
||||
assert agent.version_role_arns == {}
|
||||
|
||||
assert len(results) == 2
|
||||
assert {report.status for report in results} == {"PASS"}
|
||||
|
||||
@pytest.mark.parametrize("alias_status", ["CREATING", "UPDATING"])
|
||||
@mock_aws
|
||||
def test_in_flight_alias_status_makes_version_inventory_incomplete(
|
||||
self, alias_status
|
||||
):
|
||||
"""CREATING and UPDATING do not prove that their version is invocable."""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
|
||||
alias_status=alias_status,
|
||||
)
|
||||
)
|
||||
|
||||
for agent in service.all_agents.values():
|
||||
assert agent.versions_listed is False
|
||||
assert agent.version_role_arns == {}
|
||||
|
||||
assert len(results) == 2
|
||||
assert {report.status for report in results} == {"MANUAL"}
|
||||
for report in results:
|
||||
assert "deployed versions of" in report.status_extended
|
||||
|
||||
@pytest.mark.parametrize("alias_status", ["CREATING", "UPDATING"])
|
||||
@mock_aws
|
||||
def test_shared_draft_role_fails_despite_in_flight_alias(self, alias_status):
|
||||
"""Definite draft sharing outranks an incomplete alias inventory."""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN},
|
||||
alias_status=alias_status,
|
||||
draft_roles={AGENT_A_ID: DRAFT_A_ARN, AGENT_B_ID: DRAFT_A_ARN},
|
||||
)
|
||||
)
|
||||
|
||||
assert all(
|
||||
agent.versions_listed is False for agent in service.all_agents.values()
|
||||
)
|
||||
assert {report.status for report in results} == {"FAIL"}
|
||||
assert all(DRAFT_A_ARN in report.status_extended for report in results)
|
||||
|
||||
@mock_aws
|
||||
def test_prepared_accepting_alias_shares_its_version_role(self):
|
||||
"""A PREPARED alias accepting invocations is active."""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
|
||||
alias_status="PREPARED",
|
||||
alias_invocation_state="ACCEPT_INVOCATIONS",
|
||||
)
|
||||
)
|
||||
|
||||
for agent in service.all_agents.values():
|
||||
assert agent.versions_listed is True
|
||||
assert agent.version_role_arns == {"3": SHARED_VERSION_ARN}
|
||||
|
||||
assert {report.status for report in results} == {"FAIL"}
|
||||
|
||||
@mock_aws
|
||||
def test_prepared_alias_sharing_fails_despite_in_flight_alias(self):
|
||||
"""Definite prepared-version sharing outranks incomplete inventory."""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
|
||||
aliases=[
|
||||
{
|
||||
"agentAliasId": "alias-prepared",
|
||||
"agentAliasName": "production",
|
||||
"agentAliasStatus": "PREPARED",
|
||||
"routingConfiguration": [{"agentVersion": "3"}],
|
||||
},
|
||||
{
|
||||
"agentAliasId": "alias-updating",
|
||||
"agentAliasName": "next",
|
||||
"agentAliasStatus": "UPDATING",
|
||||
"routingConfiguration": [{"agentVersion": "4"}],
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
for agent in service.all_agents.values():
|
||||
assert agent.versions_listed is False
|
||||
assert agent.version_role_arns == {"3": SHARED_VERSION_ARN}
|
||||
|
||||
assert {report.status for report in results} == {"FAIL"}
|
||||
assert all("through deployed version 3" in r.status_extended for r in results)
|
||||
|
||||
@mock_aws
|
||||
def test_absent_invocation_state_still_shares_its_version_role(self):
|
||||
"""aliasInvocationState is optional; absent means never set to reject.
|
||||
|
||||
Reading absence as "not accepting" would report every alias that was
|
||||
never explicitly enabled as dead, and a genuinely shared role as PASS.
|
||||
"""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
|
||||
alias_invocation_state=None,
|
||||
)
|
||||
)
|
||||
|
||||
for agent in service.all_agents.values():
|
||||
assert agent.version_role_arns == {"3": SHARED_VERSION_ARN}
|
||||
|
||||
assert {report.status for report in results} == {"FAIL"}
|
||||
|
||||
@mock_aws
|
||||
def test_rejecting_alias_does_not_mask_a_shared_draft_role(self):
|
||||
"""The predicate must gate ROUTING only, never the draft.
|
||||
|
||||
The draft role comes from GetAgent, not from an alias, so an unreachable
|
||||
alias has no bearing on it. Without this, a predicate applied one level
|
||||
too high would silence a genuinely shared draft role.
|
||||
"""
|
||||
service, results = _run(
|
||||
_mock(
|
||||
{AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN},
|
||||
alias_invocation_state="REJECT_INVOCATIONS",
|
||||
)
|
||||
)
|
||||
# No routed version survives the predicate...
|
||||
for agent in service.all_agents.values():
|
||||
assert agent.version_role_arns == {}
|
||||
# ...so make both DRAFTS share one role and re-run the verdict.
|
||||
for agent in service.all_agents.values():
|
||||
agent.role_arn = DRAFT_A_ARN
|
||||
|
||||
check_name = "bedrock_agent_role_not_shared_across_agents"
|
||||
with mock.patch(
|
||||
f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}"
|
||||
".bedrock_agent_client",
|
||||
new=service,
|
||||
):
|
||||
module = __import__(
|
||||
f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}",
|
||||
fromlist=[check_name],
|
||||
)
|
||||
results = getattr(module, check_name)().execute()
|
||||
|
||||
assert len(results) == 2
|
||||
assert {report.status for report in results} == {"FAIL"}
|
||||
for report in results:
|
||||
assert DRAFT_A_ARN in report.status_extended
|
||||
# The sharing is on the draft, so no version is named.
|
||||
assert "through deployed version" not in report.status_extended
|
||||
@@ -1,287 +0,0 @@
|
||||
from unittest import mock
|
||||
|
||||
import botocore
|
||||
from botocore.exceptions import ClientError
|
||||
from moto import mock_aws
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
MODEL_NAME = "test-custom-model"
|
||||
MODEL_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:custom-model/example.base-v1/{MODEL_NAME}"
|
||||
KMS_KEY_ARN = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/test-key-id"
|
||||
FOREIGN_ACCOUNT = "999999999999"
|
||||
FOREIGN_MODEL_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{FOREIGN_ACCOUNT}:custom-model/example.base-v1/foreign-model"
|
||||
|
||||
# Operations the Bedrock constructor calls that these tests do not exercise.
|
||||
_UNUSED_OPERATIONS = (
|
||||
"GetModelInvocationLoggingConfiguration",
|
||||
"ListGuardrails",
|
||||
"GetGuardrail",
|
||||
"ListTagsForResource",
|
||||
)
|
||||
|
||||
|
||||
def _custom_model_mock(kms_key_arn=None, fail_get=False):
|
||||
"""Build a _make_api_call replacement returning one custom model."""
|
||||
|
||||
def _mock(self, operation_name, kwarg):
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListCustomModels":
|
||||
return {
|
||||
"modelSummaries": [
|
||||
{"modelArn": MODEL_ARN, "modelName": MODEL_NAME},
|
||||
]
|
||||
}
|
||||
if operation_name == "GetCustomModel":
|
||||
if fail_get:
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
response = {"modelArn": MODEL_ARN, "modelName": MODEL_NAME}
|
||||
if kms_key_arn is not None:
|
||||
response["modelKmsKeyArn"] = kms_key_arn
|
||||
return response
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
_mock_with_cmk = _custom_model_mock(KMS_KEY_ARN)
|
||||
_mock_without_cmk = _custom_model_mock(None)
|
||||
_mock_empty_cmk = _custom_model_mock("")
|
||||
_mock_unreadable = _custom_model_mock(fail_get=True)
|
||||
|
||||
|
||||
def _mock_empty(self, operation_name, kwarg):
|
||||
"""No custom models at all."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListCustomModels":
|
||||
return {"modelSummaries": []}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_unsupported_region(self, operation_name, kwarg):
|
||||
"""The API is not available in the audited region."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListCustomModels":
|
||||
raise ClientError(
|
||||
{
|
||||
"Error": {
|
||||
"Code": "ValidationException",
|
||||
"Message": "Bedrock is not supported in this region.",
|
||||
}
|
||||
},
|
||||
operation_name,
|
||||
)
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_list_denied(self, operation_name, kwarg):
|
||||
"""ListCustomModels is denied, so the region's models are unknown."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListCustomModels":
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_shared_in_model(self, operation_name, kwarg):
|
||||
"""Assert isOwned=True is sent, and return a model only when it is.
|
||||
|
||||
A caller that omits isOwned would also receive models shared into this
|
||||
account through RAM, whose KMS key this account cannot set.
|
||||
"""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListCustomModels":
|
||||
if kwarg.get("isOwned") is not True:
|
||||
# What the API would return without the filter: a foreign-owned
|
||||
# model this account cannot remediate.
|
||||
return {
|
||||
"modelSummaries": [
|
||||
{
|
||||
"modelArn": FOREIGN_MODEL_ARN,
|
||||
"modelName": "foreign-model",
|
||||
"ownerAccountId": FOREIGN_ACCOUNT,
|
||||
}
|
||||
]
|
||||
}
|
||||
return {"modelSummaries": []}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
class Test_bedrock_custom_model_encrypted_with_cmk:
|
||||
"""Unit tests for the bedrock_custom_model_encrypted_with_cmk check."""
|
||||
|
||||
def _run(self):
|
||||
"""Import the service + check under the active mocks and execute."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_custom_model_encrypted_with_cmk.bedrock_custom_model_encrypted_with_cmk.bedrock_client",
|
||||
new=Bedrock(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_custom_model_encrypted_with_cmk.bedrock_custom_model_encrypted_with_cmk import (
|
||||
bedrock_custom_model_encrypted_with_cmk,
|
||||
)
|
||||
|
||||
return bedrock_custom_model_encrypted_with_cmk().execute()
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty)
|
||||
@mock_aws
|
||||
def test_no_resources(self):
|
||||
"""No resources means no findings, not a spurious FAIL."""
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region
|
||||
)
|
||||
@mock_aws
|
||||
def test_region_not_supported(self):
|
||||
"""A ValidationException from the region must not raise; it yields no findings."""
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_with_cmk)
|
||||
@mock_aws
|
||||
def test_cmk_present_passes(self):
|
||||
"""A model with modelKmsKeyArn set is compliant."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].resource_id == MODEL_NAME
|
||||
assert result[0].resource_arn == MODEL_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Bedrock custom model {MODEL_NAME} is encrypted with a customer-managed KMS key in region {AWS_REGION_US_EAST_1}."
|
||||
)
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_without_cmk)
|
||||
@mock_aws
|
||||
def test_no_cmk_fails(self):
|
||||
"""An absent modelKmsKeyArn means an AWS-owned key is in use."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is not encrypted with a customer-managed KMS key" in (
|
||||
result[0].status_extended
|
||||
)
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty_cmk)
|
||||
@mock_aws
|
||||
def test_empty_cmk_fails(self):
|
||||
"""An empty modelKmsKeyArn string is not a key."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is not encrypted with a customer-managed KMS key" in (
|
||||
result[0].status_extended
|
||||
)
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_unreadable)
|
||||
@mock_aws
|
||||
def test_detail_unreadable_is_manual_not_pass(self):
|
||||
"""A failed GetCustomModel must not be reported as compliant."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "could not be retrieved" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_list_denied)
|
||||
@mock_aws
|
||||
def test_list_denied_is_manual_not_silence(self):
|
||||
"""A denied ListCustomModels must report MANUAL, not vanish.
|
||||
|
||||
Without a region-level report the region is indistinguishable from one
|
||||
that genuinely holds no custom models.
|
||||
"""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].resource_id == "custom-model/unknown"
|
||||
assert (
|
||||
result[0].resource_arn
|
||||
== f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:custom-model/unknown"
|
||||
)
|
||||
assert "could not be listed" in result[0].status_extended
|
||||
assert "AccessDeniedException" in result[0].status_extended
|
||||
assert result[0].status_extended.endswith(".")
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_shared_in_model)
|
||||
@mock_aws
|
||||
def test_shared_in_models_are_not_audited(self):
|
||||
"""ListCustomModels must be called with isOwned=True.
|
||||
|
||||
The mock returns a foreign-owned model only when the filter is absent, so
|
||||
a regression that drops isOwned produces a FAIL naming another account's
|
||||
model — a finding this account cannot remediate.
|
||||
"""
|
||||
result = self._run()
|
||||
assert result == []
|
||||
|
||||
def test_every_model_state_resolves_correctly(self):
|
||||
"""Exhaust the decision space instead of sampling it.
|
||||
|
||||
A Bedrock custom model cannot be created without a model customization
|
||||
job, so this check's behaviour is pinned by enumerating every combination
|
||||
of retrieval outcome, key value (absent, empty, set) and regional listing
|
||||
outcome, and asserting the per-model verdict plus the presence of the
|
||||
region-level report.
|
||||
|
||||
Each case is driven through the real Bedrock service so a renamed service
|
||||
attribute breaks the test rather than passing silently.
|
||||
"""
|
||||
from itertools import product
|
||||
|
||||
for key, fail_get, list_denied in product(
|
||||
[None, "", KMS_KEY_ARN], [False, True], [False, True]
|
||||
):
|
||||
case = (key, fail_get, list_denied)
|
||||
stub = (
|
||||
_mock_list_denied
|
||||
if list_denied
|
||||
else _custom_model_mock(key, fail_get=fail_get)
|
||||
)
|
||||
with mock.patch("botocore.client.BaseClient._make_api_call", new=stub):
|
||||
with mock_aws():
|
||||
result = self._run()
|
||||
|
||||
per_model = [
|
||||
r for r in result if "custom-model/unknown" not in r.resource_arn
|
||||
]
|
||||
region_level = [
|
||||
r for r in result if "custom-model/unknown" in r.resource_arn
|
||||
]
|
||||
|
||||
if list_denied:
|
||||
# No model reaches the inventory; only the region-level report.
|
||||
assert per_model == [], case
|
||||
assert len(region_level) == 1, case
|
||||
assert region_level[0].status == "MANUAL", case
|
||||
else:
|
||||
assert len(per_model) == 1, case
|
||||
expected = "MANUAL" if fail_get else ("PASS" if key else "FAIL")
|
||||
assert per_model[0].status == expected, case
|
||||
assert region_level == [], case
|
||||
assert all(r.status_extended.endswith(".") for r in result), case
|
||||
@@ -1,466 +0,0 @@
|
||||
from unittest import mock
|
||||
|
||||
import botocore
|
||||
from botocore.exceptions import ClientError
|
||||
from moto import mock_aws
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
GUARDRAIL_ID = "test-guardrail-id"
|
||||
GUARDRAIL_NAME = "test-guardrail"
|
||||
GUARDRAIL_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/{GUARDRAIL_ID}"
|
||||
|
||||
# Operations the Bedrock constructor calls that these tests do not exercise.
|
||||
_UNUSED_OPERATIONS = (
|
||||
"GetModelInvocationLoggingConfiguration",
|
||||
"ListTagsForResource",
|
||||
"ListCustomModels",
|
||||
)
|
||||
|
||||
|
||||
def _guardrail_mock(grounding_policy=None, fail_get=False):
|
||||
"""Build a _make_api_call replacement returning one guardrail."""
|
||||
|
||||
def _mock(self, operation_name, kwarg):
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListGuardrails":
|
||||
return {
|
||||
"guardrails": [
|
||||
{
|
||||
"id": GUARDRAIL_ID,
|
||||
"name": GUARDRAIL_NAME,
|
||||
"arn": GUARDRAIL_ARN,
|
||||
"status": "READY",
|
||||
}
|
||||
]
|
||||
}
|
||||
if operation_name == "GetGuardrail":
|
||||
if fail_get:
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
response = {
|
||||
"guardrailId": GUARDRAIL_ID,
|
||||
"guardrailArn": GUARDRAIL_ARN,
|
||||
"name": GUARDRAIL_NAME,
|
||||
"status": "READY",
|
||||
}
|
||||
if grounding_policy is not None:
|
||||
response["contextualGroundingPolicy"] = grounding_policy
|
||||
return response
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
def _filter(filter_type, threshold=0.75, action="BLOCK", enabled=True):
|
||||
"""Build one contextual grounding filter; a None value omits that key.
|
||||
|
||||
Both action and enabled are optional members of the API shape, so omitting
|
||||
either has to be expressible here to test the unknown paths.
|
||||
"""
|
||||
filter = {"type": filter_type, "threshold": threshold}
|
||||
if action is not None:
|
||||
filter["action"] = action
|
||||
if enabled is not None:
|
||||
filter["enabled"] = enabled
|
||||
return filter
|
||||
|
||||
|
||||
_mock_action_absent = _guardrail_mock(
|
||||
{
|
||||
"filters": [
|
||||
_filter("GROUNDING", action=None),
|
||||
_filter("RELEVANCE", action=None),
|
||||
]
|
||||
}
|
||||
)
|
||||
_mock_action_absent_one_filter = _guardrail_mock(
|
||||
{"filters": [_filter("GROUNDING", action=None), _filter("RELEVANCE")]}
|
||||
)
|
||||
_mock_action_absent_with_zero_threshold = _guardrail_mock(
|
||||
{
|
||||
"filters": [
|
||||
_filter("GROUNDING", action=None, threshold=0.0),
|
||||
_filter("RELEVANCE"),
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_mock_both_blocking = _guardrail_mock(
|
||||
{"filters": [_filter("GROUNDING"), _filter("RELEVANCE")]}
|
||||
)
|
||||
_mock_no_policy = _guardrail_mock(None)
|
||||
_mock_empty_filters = _guardrail_mock({"filters": []})
|
||||
_mock_missing_relevance = _guardrail_mock({"filters": [_filter("GROUNDING")]})
|
||||
_mock_missing_grounding = _guardrail_mock({"filters": [_filter("RELEVANCE")]})
|
||||
# A policy carrying only an unrecognised filter type: the policy exists, so it is
|
||||
# not the "no policy" case, yet both required types are absent at once.
|
||||
_mock_missing_both = _guardrail_mock({"filters": [_filter("UNKNOWN_TYPE")]})
|
||||
_mock_action_none = _guardrail_mock(
|
||||
{"filters": [_filter("GROUNDING", action="NONE"), _filter("RELEVANCE")]}
|
||||
)
|
||||
_mock_zero_threshold = _guardrail_mock(
|
||||
{"filters": [_filter("GROUNDING"), _filter("RELEVANCE", threshold=0.0)]}
|
||||
)
|
||||
_mock_disabled = _guardrail_mock(
|
||||
{"filters": [_filter("GROUNDING", enabled=False), _filter("RELEVANCE")]}
|
||||
)
|
||||
_mock_both_disabled = _guardrail_mock(
|
||||
{
|
||||
"filters": [
|
||||
_filter("GROUNDING", enabled=False),
|
||||
_filter("RELEVANCE", enabled=False),
|
||||
]
|
||||
}
|
||||
)
|
||||
_mock_disabled_and_action_none = _guardrail_mock(
|
||||
{
|
||||
"filters": [
|
||||
_filter("GROUNDING", enabled=False, action="NONE"),
|
||||
_filter("RELEVANCE"),
|
||||
]
|
||||
}
|
||||
)
|
||||
_mock_enabled_absent = _guardrail_mock(
|
||||
{
|
||||
"filters": [
|
||||
_filter("GROUNDING", enabled=None),
|
||||
_filter("RELEVANCE", enabled=None),
|
||||
]
|
||||
}
|
||||
)
|
||||
_mock_enabled_absent_one_filter = _guardrail_mock(
|
||||
{"filters": [_filter("GROUNDING", enabled=None), _filter("RELEVANCE")]}
|
||||
)
|
||||
_mock_enabled_absent_with_action_none = _guardrail_mock(
|
||||
{
|
||||
"filters": [
|
||||
_filter("GROUNDING", enabled=None, action="NONE"),
|
||||
_filter("RELEVANCE", enabled=None),
|
||||
]
|
||||
}
|
||||
)
|
||||
_mock_unreadable = _guardrail_mock(fail_get=True)
|
||||
|
||||
|
||||
def _mock_empty(self, operation_name, kwarg):
|
||||
"""No guardrails at all."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListGuardrails":
|
||||
return {"guardrails": []}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_list_guardrails_denied(self, operation_name, kwarg):
|
||||
"""ListGuardrails is denied, so the region's guardrails are unknown."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListGuardrails":
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_unsupported_region(self, operation_name, kwarg):
|
||||
"""The API is not available in the audited region."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListGuardrails":
|
||||
raise ClientError(
|
||||
{
|
||||
"Error": {
|
||||
"Code": "ValidationException",
|
||||
"Message": "Bedrock is not supported in this region.",
|
||||
}
|
||||
},
|
||||
operation_name,
|
||||
)
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
class Test_bedrock_guardrail_contextual_grounding_filter_enabled:
|
||||
"""Unit tests for the bedrock_guardrail_contextual_grounding_filter_enabled check."""
|
||||
|
||||
def _run(self):
|
||||
"""Import the service + check under the active mocks and execute."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_guardrail_contextual_grounding_filter_enabled.bedrock_guardrail_contextual_grounding_filter_enabled.bedrock_client",
|
||||
new=Bedrock(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_guardrail_contextual_grounding_filter_enabled.bedrock_guardrail_contextual_grounding_filter_enabled import (
|
||||
bedrock_guardrail_contextual_grounding_filter_enabled,
|
||||
)
|
||||
|
||||
return bedrock_guardrail_contextual_grounding_filter_enabled().execute()
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty)
|
||||
@mock_aws
|
||||
def test_no_resources(self):
|
||||
"""No resources means no findings, not a spurious FAIL."""
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region
|
||||
)
|
||||
@mock_aws
|
||||
def test_region_not_supported(self):
|
||||
"""A ValidationException from the region must not raise; it yields no findings."""
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_both_blocking)
|
||||
@mock_aws
|
||||
def test_both_filters_blocking_passes(self):
|
||||
"""Both filter types enabled and blocking above a zero threshold is compliant."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].resource_id == GUARDRAIL_ID
|
||||
assert result[0].resource_arn == GUARDRAIL_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert "blocks ungrounded and irrelevant responses" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_no_policy)
|
||||
@mock_aws
|
||||
def test_no_grounding_policy_fails(self):
|
||||
"""No contextual grounding policy at all means nothing is ever detected."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "no contextual grounding policy" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty_filters)
|
||||
@mock_aws
|
||||
def test_empty_filter_list_fails(self):
|
||||
"""A policy present but carrying no filters is equivalent to no policy."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "no contextual grounding policy" in result[0].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_missing_relevance
|
||||
)
|
||||
@mock_aws
|
||||
def test_missing_relevance_filter_fails(self):
|
||||
"""A GROUNDING filter alone leaves irrelevant answers unchecked."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "missing the RELEVANCE filter" in result[0].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_missing_grounding
|
||||
)
|
||||
@mock_aws
|
||||
def test_missing_grounding_filter_fails(self):
|
||||
"""A RELEVANCE filter alone leaves unsupported answers unchecked."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "missing the GROUNDING filter" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_missing_both)
|
||||
@mock_aws
|
||||
def test_missing_both_filters_fails_with_plural_wording(self):
|
||||
"""Both required types can be absent at once, so the nouns must agree.
|
||||
|
||||
The message lists the missing types, so hard-coding "filter" and "that
|
||||
class" would read "missing the GROUNDING, RELEVANCE filter ... leaving
|
||||
that class of ungrounded response unchecked".
|
||||
"""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "missing the GROUNDING, RELEVANCE filters" in result[0].status_extended
|
||||
assert "leaving those classes of ungrounded response" in (
|
||||
result[0].status_extended
|
||||
)
|
||||
assert result[0].status_extended.endswith(".")
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_action_none)
|
||||
@mock_aws
|
||||
def test_action_none_fails(self):
|
||||
"""Action NONE scores and reports without blocking, so it must FAIL."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "GROUNDING filter uses action NONE" in result[0].status_extended
|
||||
assert "without blocking" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_zero_threshold)
|
||||
@mock_aws
|
||||
def test_zero_threshold_fails(self):
|
||||
"""A threshold of 0 can never be tripped, so the filter blocks nothing."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "RELEVANCE filter has a threshold of 0" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_disabled)
|
||||
@mock_aws
|
||||
def test_disabled_filter_fails(self):
|
||||
"""enabled: false runs no evaluation, so BLOCK and a real threshold are inert."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "GROUNDING filter is disabled" in result[0].status_extended
|
||||
assert "its evaluation never runs" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_both_disabled)
|
||||
@mock_aws
|
||||
def test_both_filters_disabled_fails(self):
|
||||
"""Both filters disabled reports both, not just the first."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "GROUNDING filter is disabled" in result[0].status_extended
|
||||
assert "RELEVANCE filter is disabled" in result[0].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_disabled_and_action_none
|
||||
)
|
||||
@mock_aws
|
||||
def test_disabled_reported_ahead_of_action(self):
|
||||
"""Disabled is the operative defect: the action is never reached."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "GROUNDING filter is disabled" in result[0].status_extended
|
||||
assert "action NONE" not in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_enabled_absent)
|
||||
@mock_aws
|
||||
def test_absent_enabled_is_manual_not_pass(self):
|
||||
"""enabled is optional with no documented default, so absent is unknown."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert (
|
||||
"GROUNDING filter omits enabled, RELEVANCE filter omits enabled"
|
||||
in result[0].status_extended
|
||||
)
|
||||
# Two unknown filters take the plural subject.
|
||||
assert "so whether they block is unknown" in result[0].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_enabled_absent_one_filter
|
||||
)
|
||||
@mock_aws
|
||||
def test_absent_enabled_on_one_filter_is_manual(self):
|
||||
"""One filter omitting enabled is enough to make the answer unknown."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "GROUNDING filter omits enabled" in result[0].status_extended
|
||||
# action was present, so it must not be reported as missing.
|
||||
assert "omits action" not in result[0].status_extended
|
||||
assert "RELEVANCE" not in result[0].status_extended
|
||||
assert "so whether it blocks is unknown" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_action_absent)
|
||||
@mock_aws
|
||||
def test_absent_action_is_manual_not_fail(self):
|
||||
"""action is optional with no documented default, so omitting it is unknown.
|
||||
|
||||
Treating an absent action as NONE would assert a misconfiguration the
|
||||
response never stated, and would print the literal None as if it were an
|
||||
AWS enum value. It is reported the same way as an absent enabled.
|
||||
"""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "None" not in result[0].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_action_absent_one_filter
|
||||
)
|
||||
@mock_aws
|
||||
def test_absent_action_on_one_filter_is_manual(self):
|
||||
"""One filter omitting action is enough to make the answer unknown."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "GROUNDING filter omits action" in result[0].status_extended
|
||||
# enabled was present, so it must not be reported as missing.
|
||||
assert "omits enabled" not in result[0].status_extended
|
||||
assert "RELEVANCE" not in result[0].status_extended
|
||||
assert "so whether it blocks is unknown" in result[0].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_mock_action_absent_with_zero_threshold,
|
||||
)
|
||||
@mock_aws
|
||||
def test_absent_action_does_not_mask_a_definite_defect(self):
|
||||
"""An unknown action must not downgrade a real threshold defect to MANUAL."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "threshold" in result[0].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_mock_enabled_absent_with_action_none,
|
||||
)
|
||||
@mock_aws
|
||||
def test_absent_enabled_does_not_mask_a_definite_defect(self):
|
||||
"""An unknown enabled must not downgrade a real action defect to MANUAL."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "GROUNDING filter uses action NONE" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_unreadable)
|
||||
@mock_aws
|
||||
def test_detail_unreadable_is_manual_not_pass(self):
|
||||
"""A failed GetGuardrail must not be reported as compliant."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "could not be retrieved" in result[0].status_extended
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_list_guardrails_denied
|
||||
)
|
||||
@mock_aws
|
||||
def test_list_guardrails_denied_is_manual_not_silence(self):
|
||||
"""A denied ListGuardrails must report MANUAL for the region, not vanish.
|
||||
|
||||
Without this the Region is indistinguishable from one holding no
|
||||
guardrails, which is the same silent-inventory gap the sibling checks
|
||||
report against custom-model/unknown and knowledge-base/unknown.
|
||||
"""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].resource_id == "guardrail/unknown"
|
||||
assert (
|
||||
result[0].resource_arn
|
||||
== f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/unknown"
|
||||
)
|
||||
assert "could not be listed" in result[0].status_extended
|
||||
assert "AccessDeniedException" in result[0].status_extended
|
||||
assert result[0].status_extended.endswith(".")
|
||||
@@ -1,338 +0,0 @@
|
||||
from unittest import mock
|
||||
|
||||
import botocore
|
||||
from botocore.exceptions import ClientError
|
||||
from moto import mock_aws
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
KB_ID = "test-kb-id"
|
||||
KB_NAME = "test-knowledge-base"
|
||||
KB_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:knowledge-base/{KB_ID}"
|
||||
DS_ID = "test-ds-id"
|
||||
DS_NAME = "test-data-source"
|
||||
DS_ARN = f"{KB_ARN}/data-source/{DS_ID}"
|
||||
KMS_KEY_ARN = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/test-key-id"
|
||||
|
||||
# Operations the BedrockAgent constructor calls that these tests do not exercise.
|
||||
_UNUSED_OPERATIONS = (
|
||||
"ListAgents",
|
||||
"GetAgent",
|
||||
"ListPrompts",
|
||||
"GetPrompt",
|
||||
"ListTagsForResource",
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_base_mock(kms_key_arn=None, fail_get=False):
|
||||
"""Build a _make_api_call replacement returning one KB with one data source."""
|
||||
|
||||
def _mock(self, operation_name, kwarg):
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListKnowledgeBases":
|
||||
return {
|
||||
"knowledgeBaseSummaries": [
|
||||
{
|
||||
"knowledgeBaseId": KB_ID,
|
||||
"name": KB_NAME,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
]
|
||||
}
|
||||
if operation_name == "ListDataSources":
|
||||
return {
|
||||
"dataSourceSummaries": [
|
||||
{
|
||||
"knowledgeBaseId": KB_ID,
|
||||
"dataSourceId": DS_ID,
|
||||
"name": DS_NAME,
|
||||
"status": "AVAILABLE",
|
||||
}
|
||||
]
|
||||
}
|
||||
if operation_name == "GetDataSource":
|
||||
if fail_get:
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
# GetDataSource nests its payload under a top-level dataSource key.
|
||||
data_source = {
|
||||
"knowledgeBaseId": KB_ID,
|
||||
"dataSourceId": DS_ID,
|
||||
"name": DS_NAME,
|
||||
"status": "AVAILABLE",
|
||||
}
|
||||
if kms_key_arn is not None:
|
||||
data_source["serverSideEncryptionConfiguration"] = {
|
||||
"kmsKeyArn": kms_key_arn
|
||||
}
|
||||
return {"dataSource": data_source}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
_mock_with_cmk = _knowledge_base_mock(KMS_KEY_ARN)
|
||||
_mock_without_cmk = _knowledge_base_mock(None)
|
||||
_mock_empty_cmk = _knowledge_base_mock("")
|
||||
_mock_unreadable = _knowledge_base_mock(fail_get=True)
|
||||
|
||||
|
||||
def _mock_empty(self, operation_name, kwarg):
|
||||
"""No knowledge bases at all."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListKnowledgeBases":
|
||||
return {"knowledgeBaseSummaries": []}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_no_data_sources(self, operation_name, kwarg):
|
||||
"""A knowledge base with no data sources produces no findings."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListKnowledgeBases":
|
||||
return {
|
||||
"knowledgeBaseSummaries": [
|
||||
{"knowledgeBaseId": KB_ID, "name": KB_NAME, "status": "ACTIVE"}
|
||||
]
|
||||
}
|
||||
if operation_name == "ListDataSources":
|
||||
return {"dataSourceSummaries": []}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_unsupported_region(self, operation_name, kwarg):
|
||||
"""The API is not available in the audited region."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListKnowledgeBases":
|
||||
raise ClientError(
|
||||
{
|
||||
"Error": {
|
||||
"Code": "ValidationException",
|
||||
"Message": "Bedrock Agent is not supported in this region.",
|
||||
}
|
||||
},
|
||||
operation_name,
|
||||
)
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_list_kb_denied(self, operation_name, kwarg):
|
||||
"""ListKnowledgeBases is denied, so the region's knowledge bases are unknown."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListKnowledgeBases":
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def _mock_list_ds_denied(self, operation_name, kwarg):
|
||||
"""The knowledge base is visible but its data sources cannot be listed."""
|
||||
if operation_name in _UNUSED_OPERATIONS:
|
||||
return {}
|
||||
if operation_name == "ListKnowledgeBases":
|
||||
return {
|
||||
"knowledgeBaseSummaries": [
|
||||
{"knowledgeBaseId": KB_ID, "name": KB_NAME, "status": "ACTIVE"}
|
||||
]
|
||||
}
|
||||
if operation_name == "ListDataSources":
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
operation_name,
|
||||
)
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
class Test_bedrock_knowledge_base_encrypted_with_cmk:
|
||||
"""Unit tests for the bedrock_knowledge_base_encrypted_with_cmk check."""
|
||||
|
||||
def _run(self):
|
||||
"""Import the service + check under the active mocks and execute."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_knowledge_base_encrypted_with_cmk.bedrock_knowledge_base_encrypted_with_cmk.bedrock_agent_client",
|
||||
new=BedrockAgent(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_knowledge_base_encrypted_with_cmk.bedrock_knowledge_base_encrypted_with_cmk import (
|
||||
bedrock_knowledge_base_encrypted_with_cmk,
|
||||
)
|
||||
|
||||
return bedrock_knowledge_base_encrypted_with_cmk().execute()
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty)
|
||||
@mock_aws
|
||||
def test_no_resources(self):
|
||||
"""No resources means no findings, not a spurious FAIL."""
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_no_data_sources)
|
||||
@mock_aws
|
||||
def test_knowledge_base_without_data_sources(self):
|
||||
"""Findings are per data source, so a KB with none produces nothing."""
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region
|
||||
)
|
||||
@mock_aws
|
||||
def test_region_not_supported(self):
|
||||
"""A ValidationException from the region must not raise; it yields no findings."""
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_with_cmk)
|
||||
@mock_aws
|
||||
def test_cmk_present_passes(self):
|
||||
"""A data source with kmsKeyArn set is compliant."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].resource_id == DS_ID
|
||||
assert result[0].resource_arn == DS_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Bedrock knowledge base {KB_NAME} data source {DS_NAME} is encrypted with a customer-managed KMS key in region {AWS_REGION_US_EAST_1}."
|
||||
)
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_without_cmk)
|
||||
@mock_aws
|
||||
def test_no_cmk_fails(self):
|
||||
"""An absent kmsKeyArn means an AWS-owned key is in use."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert KB_NAME in result[0].status_extended
|
||||
assert "is not encrypted with a customer-managed KMS key" in (
|
||||
result[0].status_extended
|
||||
)
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty_cmk)
|
||||
@mock_aws
|
||||
def test_empty_cmk_fails(self):
|
||||
"""An empty kmsKeyArn string is not a key."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is not encrypted with a customer-managed KMS key" in (
|
||||
result[0].status_extended
|
||||
)
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_unreadable)
|
||||
@mock_aws
|
||||
def test_detail_unreadable_is_manual_not_pass(self):
|
||||
"""A failed GetDataSource must not be reported as compliant."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "could not be retrieved" in result[0].status_extended
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_list_kb_denied)
|
||||
@mock_aws
|
||||
def test_list_knowledge_bases_denied_is_manual_not_silence(self):
|
||||
"""A denied ListKnowledgeBases must report MANUAL for the region."""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].resource_id == "knowledge-base/unknown"
|
||||
assert (
|
||||
result[0].resource_arn
|
||||
== f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:knowledge-base/unknown"
|
||||
)
|
||||
assert "could not be listed" in result[0].status_extended
|
||||
assert "AccessDeniedException" in result[0].status_extended
|
||||
assert result[0].status_extended.endswith(".")
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_list_ds_denied)
|
||||
@mock_aws
|
||||
def test_list_data_sources_denied_is_manual_not_silence(self):
|
||||
"""A knowledge base whose data sources cannot be listed must still report.
|
||||
|
||||
Reporting nothing would drop the knowledge base from the output, which is
|
||||
indistinguishable from one that genuinely has no data sources.
|
||||
"""
|
||||
result = self._run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert result[0].resource_arn == KB_ARN
|
||||
assert KB_NAME in result[0].status_extended
|
||||
assert "data sources could not be listed" in result[0].status_extended
|
||||
# The message names why, like the region-level and detail-level ones do.
|
||||
assert "AccessDeniedException" in result[0].status_extended
|
||||
assert result[0].status_extended.endswith(".")
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_no_data_sources)
|
||||
@mock_aws
|
||||
def test_listed_but_empty_marks_the_knowledge_base_as_listed(self):
|
||||
"""A successful but empty ListDataSources is "none", not "unknown".
|
||||
|
||||
Distinct from test_knowledge_base_without_data_sources, which only asserts
|
||||
the empty result: this asserts the service state that produces it, so the
|
||||
over-correction of reporting MANUAL for a genuinely empty knowledge base
|
||||
cannot regress silently.
|
||||
"""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
with mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
):
|
||||
service = BedrockAgent(aws_provider)
|
||||
|
||||
assert service.knowledge_bases, "the knowledge base must be discovered"
|
||||
assert all(
|
||||
knowledge_base.data_sources_listed
|
||||
for knowledge_base in service.knowledge_bases.values()
|
||||
)
|
||||
assert service.data_sources == {}
|
||||
assert service.knowledge_bases_scan_errors == {}
|
||||
assert self._run() == []
|
||||
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_without_cmk)
|
||||
@mock_aws
|
||||
def test_scoping_by_knowledge_base_arn_keeps_its_data_sources(self):
|
||||
"""A scan scoped to the knowledge base ARN must still see its data sources.
|
||||
|
||||
AWS exposes no ARN for a Bedrock data source, so the one built here is
|
||||
synthetic and can never equal a user-supplied --resource-arn. Filtering on
|
||||
it would keep the knowledge base, silently drop every data source, and
|
||||
leave the check reporting nothing for an in-scope knowledge base.
|
||||
"""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
aws_provider._audit_resources = [KB_ARN]
|
||||
with mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
):
|
||||
service = BedrockAgent(aws_provider)
|
||||
|
||||
assert service.knowledge_bases, "the scoped knowledge base must be kept"
|
||||
assert service.data_sources, "its data sources must not be filtered out"
|
||||
assert all(
|
||||
ds.knowledge_base_id == KB_ID for ds in service.data_sources.values()
|
||||
)
|
||||
@@ -2,7 +2,6 @@ from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
from boto3 import client
|
||||
from moto import mock_aws
|
||||
|
||||
@@ -284,20 +283,11 @@ class TestBedrockPagination:
|
||||
class TestBedrockAgentPagination:
|
||||
"""Test suite for Bedrock Agent pagination logic."""
|
||||
|
||||
@pytest.mark.parametrize("partition", ["aws", "aws-us-gov", "aws-cn"])
|
||||
def test_list_agents_pagination(self, partition):
|
||||
"""Test that list_agents iterates through all pages, in every partition.
|
||||
|
||||
The ARN is built from the audited partition, so GovCloud and China must
|
||||
produce aws-us-gov/aws-cn ARNs. A hardcoded `arn:aws:` here yielded an ARN
|
||||
that does not exist in those partitions, and exact --resource-arn matching
|
||||
against it could never succeed.
|
||||
"""
|
||||
# Mock the audit_info. AWSService reads the partition off provider.identity,
|
||||
# so setting audited_partition alone leaves a MagicMock in the ARN.
|
||||
def test_list_agents_pagination(self):
|
||||
"""Test that list_agents iterates through all pages."""
|
||||
# Mock the audit_info
|
||||
audit_info = MagicMock()
|
||||
audit_info.identity.partition = partition
|
||||
audit_info.audited_partition = partition
|
||||
audit_info.audited_partition = "aws"
|
||||
audit_info.audited_account = "123456789012"
|
||||
audit_info.audit_resources = None
|
||||
|
||||
@@ -332,7 +322,6 @@ class TestBedrockAgentPagination:
|
||||
bedrock_agent_service = BedrockAgent(audit_info)
|
||||
bedrock_agent_service.regional_clients = {"us-east-1": regional_client}
|
||||
bedrock_agent_service.agents = {} # Clear init side effects
|
||||
bedrock_agent_service.all_agents = {}
|
||||
bedrock_agent_service.audited_account = "123456789012"
|
||||
|
||||
# Run method
|
||||
@@ -340,16 +329,14 @@ class TestBedrockAgentPagination:
|
||||
|
||||
# Assertions
|
||||
assert len(bedrock_agent_service.agents) == 2
|
||||
for agent_id in ("agent-1", "agent-2"):
|
||||
expected_arn = (
|
||||
f"arn:{partition}:bedrock:us-east-1:123456789012:agent/{agent_id}"
|
||||
)
|
||||
assert expected_arn in bedrock_agent_service.agents
|
||||
# With no --resource-arn, the complete inventory and the reported set
|
||||
# hold the very same objects.
|
||||
assert bedrock_agent_service.all_agents[expected_arn] is (
|
||||
bedrock_agent_service.agents[expected_arn]
|
||||
)
|
||||
assert (
|
||||
"arn:aws:bedrock:us-east-1:123456789012:agent/agent-1"
|
||||
in bedrock_agent_service.agents
|
||||
)
|
||||
assert (
|
||||
"arn:aws:bedrock:us-east-1:123456789012:agent/agent-2"
|
||||
in bedrock_agent_service.agents
|
||||
)
|
||||
|
||||
# Verify paginator was used
|
||||
regional_client.get_paginator.assert_called_once_with("list_agents")
|
||||
|
||||
@@ -1,837 +0,0 @@
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.aws.services.ecr.ecr_service import (
|
||||
ImageDetails,
|
||||
Registry,
|
||||
Repository,
|
||||
)
|
||||
from prowler.providers.aws.services.ecr.image_inspection import (
|
||||
ImageScanData,
|
||||
ImageScanFile,
|
||||
)
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
# A real JWT: Kingfisher detects this regardless of the surrounding key name
|
||||
# or format (env-style KEY=value, Dockerfile RUN step, or source file).
|
||||
SECRET_VALUE = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
".eyJzdWIiOiIxMjM0NTY3ODkwIn0"
|
||||
".dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
|
||||
)
|
||||
|
||||
|
||||
def create_repository(name="test-repo", region=AWS_REGION_US_EAST_1) -> Repository:
|
||||
"""Build a minimal ECR Repository fixture."""
|
||||
return Repository(
|
||||
name=name,
|
||||
arn=f"arn:aws:ecr:{region}:{AWS_ACCOUNT_NUMBER}:repository/{name}",
|
||||
region=region,
|
||||
scan_on_push=True,
|
||||
images_details=[],
|
||||
)
|
||||
|
||||
|
||||
def create_image(tag="latest", digest=None) -> ImageDetails:
|
||||
"""Build a minimal ImageDetails fixture."""
|
||||
return ImageDetails(
|
||||
latest_tag=tag,
|
||||
latest_digest=digest or f"sha256:{'0' * 64}",
|
||||
image_pushed_at=datetime.now(),
|
||||
scan_findings_status=None,
|
||||
scan_findings_severity_count=None,
|
||||
artifact_media_type="application/vnd.docker.container.image.v1+json",
|
||||
type="Docker",
|
||||
)
|
||||
|
||||
|
||||
def mock_image_scan_data(pairs):
|
||||
"""Build a fake _get_image_scan_data generator yielding the given pairs."""
|
||||
|
||||
def _generator():
|
||||
"""Yield each (repository, image, scan_data) pair once."""
|
||||
for entry in pairs:
|
||||
yield entry
|
||||
|
||||
return _generator
|
||||
|
||||
|
||||
class Test_ecr_repository_image_no_secrets:
|
||||
"""Tests for the ecr_repository_image_no_secrets check."""
|
||||
|
||||
def test_no_repositories(self):
|
||||
"""No repositories yields no findings."""
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data([])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_clean_image(self):
|
||||
"""An image with no secrets passes."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=["PATH=/usr/bin"],
|
||||
history=["RUN echo hello"],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
digest_short = image.latest_digest.split(":")[-1][:12]
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].status_extended == (
|
||||
f"No secrets found in the image '{image.latest_tag}' "
|
||||
f"({image.latest_digest}) of ECR repository {repository.name}."
|
||||
)
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert (
|
||||
result[0].resource_id
|
||||
== f"{repository.name}:{image.latest_tag}@{digest_short}"
|
||||
)
|
||||
assert result[0].resource_arn == f"{repository.arn}/image/{digest_short}"
|
||||
|
||||
def test_truncated_image_reports_manual(self):
|
||||
"""A clean but truncated image is MANUAL, since part was not scanned."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(env=[], history=[], files=[], truncated=True)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert (
|
||||
"part of it could not be retrieved or exceeded configured size "
|
||||
"limits and was not scanned" in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_secret_in_environment_variable(self):
|
||||
"""A secret in an environment variable fails, naming the variable."""
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "environment variable DB_PASSWORD" in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
assert result[0].check_metadata.Severity == Severity.high
|
||||
|
||||
def test_secret_in_malformed_env_entry_is_redacted(self):
|
||||
"""An env entry without '=' is reported generically, never echoed."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
# The entry has no "=" so no variable name can be split out; the entry
|
||||
# itself is the secret and must not appear in the finding.
|
||||
scan_data = ImageScanData(
|
||||
env=[SECRET_VALUE],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "in image environment variables" in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
|
||||
def test_secret_in_unsafe_environment_name_is_redacted(self):
|
||||
"""An unsafe name before '=' is never included in report text."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[f"{SECRET_VALUE}=safe-value"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "in image environment variables" in result[0].status_extended
|
||||
assert SECRET_VALUE not in str(vars(result[0]))
|
||||
|
||||
def test_scanned_file_content_is_freed_after_execute(self):
|
||||
"""File contents are released after scanning so memory stays flat."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scanned_file = ImageScanFile(
|
||||
path="app/config.py",
|
||||
layer_digest=f"sha256:{'a' * 64}",
|
||||
content="nothing secret here",
|
||||
)
|
||||
scan_data = ImageScanData(
|
||||
env=[], history=[], files=[scanned_file], truncated=False
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
# The check empties each file's content once it is handed to the
|
||||
# scanner; only path and layer digest are needed thereafter.
|
||||
assert scanned_file.content == ""
|
||||
|
||||
def test_secrets_ignore_patterns_suppresses_finding(self):
|
||||
"""A secret matching an ignore pattern is suppressed."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [SECRET_VALUE],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_secret_in_build_history(self):
|
||||
"""A secret in a build history step fails, naming the step."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[],
|
||||
history=["RUN apt-get update", f'RUN export TOKEN="{SECRET_VALUE}"'],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "image history step 2" in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
|
||||
def test_multiline_environment_secret_keeps_entry_attribution(self):
|
||||
"""Embedded newlines do not shift an env finding to another entry."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[f"MULTILINE=prefix\r\n{SECRET_VALUE}", "WRONG=value"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert "environment variable MULTILINE" in result[0].status_extended
|
||||
assert "environment variable WRONG" not in result[0].status_extended
|
||||
|
||||
def test_multiline_history_secret_keeps_step_attribution(self):
|
||||
"""Embedded newlines do not shift a history finding to another step."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[],
|
||||
history=[f"RUN first\nexport TOKEN={SECRET_VALUE}", "RUN second"],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert "image history step 1" in result[0].status_extended
|
||||
assert "image history step 2" not in result[0].status_extended
|
||||
|
||||
def test_secret_in_layer_file(self):
|
||||
"""A secret in a layer file fails, naming the file and layer."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
layer_digest = f"sha256:{'a' * 64}"
|
||||
scan_data = ImageScanData(
|
||||
env=[],
|
||||
history=[],
|
||||
files=[
|
||||
ImageScanFile(
|
||||
path="app/config.py",
|
||||
layer_digest=layer_digest,
|
||||
content=f'TOKEN = "{SECRET_VALUE}"',
|
||||
)
|
||||
],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "file app/config.py" in result[0].status_extended
|
||||
assert layer_digest in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
|
||||
def test_manifest_unresolvable(self):
|
||||
"""An unresolvable manifest is reported as MANUAL."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, None)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert (
|
||||
"Could not resolve or retrieve the manifest"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_latest_image_lookup_error_reports_repository_manual(self):
|
||||
"""A failed authoritative image lookup is reported for the repository."""
|
||||
repository = create_repository()
|
||||
lookup_error = RuntimeError("authoritative lookup failed")
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, None, lookup_error)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "Could not determine the latest image" in result[0].status_extended
|
||||
|
||||
def test_scan_error_reports_manual_for_latest_image_per_repository(self):
|
||||
"""A scanner failure reports MANUAL once per repository's latest image."""
|
||||
from prowler.lib.utils.utils import SecretsScanError
|
||||
|
||||
# Each repository has multiple images; the scan-error fallback must
|
||||
# scope to the latest image per repository only, mirroring the
|
||||
# success-path scope, not emit one MANUAL per image.
|
||||
repo1 = create_repository(name="repo-1")
|
||||
repo1.images_details = [
|
||||
create_image(tag="v1", digest=f"sha256:{'1' * 64}"),
|
||||
create_image(tag="v2", digest=f"sha256:{'2' * 64}"),
|
||||
]
|
||||
repo2 = create_repository(name="repo-2")
|
||||
repo2.images_details = [
|
||||
create_image(tag="v1", digest=f"sha256:{'3' * 64}"),
|
||||
create_image(tag="v2", digest=f"sha256:{'4' * 64}"),
|
||||
]
|
||||
registry = Registry(
|
||||
id=AWS_ACCOUNT_NUMBER,
|
||||
arn=f"arn:aws:ecr:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}",
|
||||
region=AWS_REGION_US_EAST_1,
|
||||
repositories=[repo1, repo2],
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {AWS_REGION_US_EAST_1: registry}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
# Not consumed on this path, but must be a real generator to iterate.
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data([])
|
||||
# The error fallback resolves each repository's scan target via
|
||||
# _get_scan_target_image; mirror the real method's latest-image scope.
|
||||
ecr_client._get_scan_target_image.side_effect = lambda repository: (
|
||||
repository.images_details[-1] if repository.images_details else None
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch",
|
||||
side_effect=SecretsScanError("Scanner failure"),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
# One MANUAL per repository (its latest image), not one per image.
|
||||
assert len(result) == 2
|
||||
for report in result:
|
||||
assert report.status == "MANUAL"
|
||||
assert "Could not scan image" in report.status_extended
|
||||
assert "Scanner failure" in report.status_extended
|
||||
|
||||
digests_reported = {report.resource_id.split("@")[-1] for report in result}
|
||||
latest_digest_repo1 = repo1.images_details[-1].latest_digest.split(":")[-1][
|
||||
:12
|
||||
]
|
||||
latest_digest_repo2 = repo2.images_details[-1].latest_digest.split(":")[-1][
|
||||
:12
|
||||
]
|
||||
assert digests_reported == {latest_digest_repo1, latest_digest_repo2}
|
||||
assert "Scanner failure" in result[0].status_extended
|
||||
|
||||
def test_verified_secret_escalates_to_critical(self):
|
||||
"""A verified secret escalates severity to critical."""
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[f"TOKEN={SECRET_VALUE}"], history=[], files=[], truncated=False
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": True,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
def fake_scan_batch(payloads, **kwargs):
|
||||
# The real detect_secrets_scan_batch consumes the lazily-yielded
|
||||
# payloads generator as a side effect (that's what populates the
|
||||
# check's `scanned` list); replicate that here while returning
|
||||
# a controlled, pre-verified finding.
|
||||
"""Drain the payload generator like the real scanner, then return canned findings."""
|
||||
list(payloads)
|
||||
return {
|
||||
(0, "environment:0"): [
|
||||
{
|
||||
"type": "JSON Web Token (base64url-encoded)",
|
||||
"line_number": 1,
|
||||
"is_verified": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch",
|
||||
side_effect=fake_scan_batch,
|
||||
) as mock_scan,
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert mock_scan.call_args.kwargs.get("validate") is True
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].check_metadata.Severity == Severity.critical
|
||||
assert "confirmed to be live" in result[0].status_extended
|
||||
|
||||
def test_multiple_repositories_and_images(self):
|
||||
"""Mixed pass/fail results are reported across multiple repositories."""
|
||||
repo1 = create_repository(name="repo-1")
|
||||
repo2 = create_repository(name="repo-2")
|
||||
image1 = create_image(tag="v1", digest=f"sha256:{'1' * 64}")
|
||||
image2 = create_image(tag="v2", digest=f"sha256:{'2' * 64}")
|
||||
|
||||
clean_scan = ImageScanData(env=[], history=[], files=[], truncated=False)
|
||||
fail_scan = ImageScanData(
|
||||
env=[f"DB_PASSWORD={SECRET_VALUE}"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repo1, image1, clean_scan), (repo2, image2, fail_scan)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 2
|
||||
statuses_by_repo = {r.resource_id.split(":")[0]: r.status for r in result}
|
||||
assert statuses_by_repo["repo-1"] == "PASS"
|
||||
assert statuses_by_repo["repo-2"] == "FAIL"
|
||||
report_by_repo = {r.resource_id.split(":")[0]: r for r in result}
|
||||
assert SECRET_VALUE not in report_by_repo["repo-2"].status_extended
|
||||
@@ -1,21 +1,11 @@
|
||||
import json
|
||||
from concurrent.futures import Future
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
from boto3 import client
|
||||
from moto import mock_aws
|
||||
|
||||
from prowler.providers.aws.services.ecr.ecr_service import (
|
||||
ECR,
|
||||
ScanningRule,
|
||||
)
|
||||
from tests.providers.aws.services.ecr.image_scan_fixtures import (
|
||||
MANIFESTS_BY_DIGEST,
|
||||
reset_image_fixtures,
|
||||
)
|
||||
from prowler.providers.aws.services.ecr.ecr_service import ECR, ScanningRule
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_EU_WEST_1,
|
||||
@@ -28,20 +18,8 @@ repo_name = "test-repo"
|
||||
# Mocking Access Analyzer Calls
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
# BatchGetImage / GetDownloadUrlForLayer fixtures (which moto does not
|
||||
# implement) live in image_scan_fixtures and are served by mock_make_api_call.
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_image_fixtures():
|
||||
"""Isolate the BatchGetImage/GetDownloadUrlForLayer fixtures per test."""
|
||||
reset_image_fixtures()
|
||||
yield
|
||||
reset_image_fixtures()
|
||||
|
||||
|
||||
def mock_make_api_call(self, operation_name, kwarg):
|
||||
"""Fake botocore responses for the ECR operations this suite exercises."""
|
||||
if operation_name == "DescribeImages":
|
||||
return {
|
||||
"imageDetails": [
|
||||
@@ -172,37 +150,10 @@ def mock_make_api_call(self, operation_name, kwarg):
|
||||
},
|
||||
}
|
||||
|
||||
if operation_name == "BatchGetImage":
|
||||
digest = kwarg["imageIds"][0]["imageDigest"]
|
||||
manifest = MANIFESTS_BY_DIGEST.get(digest)
|
||||
if manifest is None:
|
||||
return {
|
||||
"images": [],
|
||||
"failures": [
|
||||
{
|
||||
"imageId": {"imageDigest": digest},
|
||||
"failureCode": "ImageNotFound",
|
||||
}
|
||||
],
|
||||
}
|
||||
return {
|
||||
"images": [
|
||||
{
|
||||
"imageManifest": json.dumps(manifest),
|
||||
"imageManifestMediaType": manifest.get("mediaType", ""),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if operation_name == "GetDownloadUrlForLayer":
|
||||
digest = kwarg["layerDigest"]
|
||||
return {"downloadUrl": f"https://layers.example.com/{digest}"}
|
||||
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def mock_generate_regional_clients(provider, service):
|
||||
"""Return a single regional client for every requested region."""
|
||||
regional_client = provider._session.current_session.client(
|
||||
service, region_name=AWS_REGION_EU_WEST_1
|
||||
)
|
||||
@@ -218,17 +169,13 @@ def mock_generate_regional_clients(provider, service):
|
||||
)
|
||||
class Test_ECR_Service:
|
||||
# Test ECR Service
|
||||
"""Tests for the ECR service."""
|
||||
|
||||
def test_service(self):
|
||||
"""The service name is set correctly."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
assert ecr.service == "ecr"
|
||||
|
||||
# Test ECR client
|
||||
def test_client(self):
|
||||
"""Each regional client is an ECR client."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
for regional_client in ecr.regional_clients.values():
|
||||
@@ -236,7 +183,6 @@ class Test_ECR_Service:
|
||||
|
||||
# Test ECR session
|
||||
def test_get_session(self):
|
||||
"""The session is set correctly."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
assert ecr.session.__class__.__name__ == "Session"
|
||||
@@ -244,7 +190,6 @@ class Test_ECR_Service:
|
||||
# Test describe ECR repositories
|
||||
@mock_aws
|
||||
def test_describe_registries_and_repositories(self):
|
||||
"""Registries and repositories are discovered."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -275,7 +220,6 @@ class Test_ECR_Service:
|
||||
# Test describe ECR repository policies
|
||||
@mock_aws
|
||||
def test_describe_repository_policies(self):
|
||||
"""Repository policies are fetched and parsed."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -305,7 +249,6 @@ class Test_ECR_Service:
|
||||
# Test describe ECR repository lifecycle policies
|
||||
@mock_aws
|
||||
def test_get_lifecycle_policies(self):
|
||||
"""Repository lifecycle policies are fetched."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -325,7 +268,6 @@ class Test_ECR_Service:
|
||||
# Test get image details
|
||||
@mock_aws
|
||||
def test_get_image_details(self):
|
||||
"""Scannable, tagged images are collected and sorted by push date."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -424,7 +366,6 @@ class Test_ECR_Service:
|
||||
# Test get ECR Registries Scanning Configuration
|
||||
@mock_aws
|
||||
def test_get_registry_scanning_configuration(self):
|
||||
"""The registry's scanning configuration is fetched."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
assert len(ecr.registries) == 1
|
||||
@@ -438,188 +379,39 @@ class Test_ECR_Service:
|
||||
]
|
||||
|
||||
def test_is_artifact_scannable_docker(self):
|
||||
"""A Docker image config is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.container.image.v1+json"
|
||||
)
|
||||
|
||||
def test_is_artifact_scannable_layer_tar(self):
|
||||
"""An uncompressed Docker layer is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.image.rootfs.diff.tar"
|
||||
)
|
||||
|
||||
def test_is_artifact_scannable_layer_gzip(self):
|
||||
"""A gzip-compressed Docker layer is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.image.rootfs.diff.tar.gzip"
|
||||
)
|
||||
|
||||
def test_is_artifact_scannable_oci(self):
|
||||
"""An OCI image config is scannable."""
|
||||
assert ECR._is_artifact_scannable("application/vnd.oci.image.config.v1+json")
|
||||
|
||||
def test_is_artifact_scannable_oci_tar(self):
|
||||
"""An uncompressed OCI layer is scannable."""
|
||||
assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar")
|
||||
|
||||
def test_is_artifact_scannable_oci_compressed(self):
|
||||
"""A gzip-compressed OCI layer is scannable."""
|
||||
assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar+gzip")
|
||||
|
||||
def test_is_artifact_scannable_none(self):
|
||||
"""A missing media type is not scannable."""
|
||||
assert not ECR._is_artifact_scannable(None)
|
||||
|
||||
def test_is_artifact_scannable_empty(self):
|
||||
"""An empty media type is not scannable."""
|
||||
assert not ECR._is_artifact_scannable("")
|
||||
|
||||
def test_is_artifact_scannable_non_scannable_tags(self):
|
||||
"""A signature-tagged artifact is not scannable."""
|
||||
assert not ECR._is_artifact_scannable("", ["sha256-abcdefg123456.sig"])
|
||||
|
||||
def test_is_artifact_scannable_scannable_tags(self):
|
||||
"""A normally-tagged artifact is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.container.image.v1+json", ["abcdefg123456"]
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_get_image_scan_data_selects_only_latest_image_per_repository(self):
|
||||
"""Only the latest image per repository is selected for scanning."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
# Sanity check: this repository has several scannable tagged images.
|
||||
assert len(repository.images_details) == 4
|
||||
|
||||
results = list(ecr._get_image_scan_data())
|
||||
|
||||
# Only the most recently pushed image is selected, not all four.
|
||||
assert len(results) == 1
|
||||
fetched_repository, fetched_image, _ = results[0]
|
||||
assert fetched_repository.name == repo_name
|
||||
assert fetched_image.latest_tag == "test-tag4"
|
||||
assert (
|
||||
fetched_image.latest_digest
|
||||
== "sha256:43251ac64627fc331584f6c498b3aba5badc01574e2c70b2499af3af16630eed"
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_get_image_scan_data_covers_scan_on_push_disabled_repository(self):
|
||||
"""A scan-on-push-disabled repo (empty images_details) is still scanned."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": False},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
# Scan-on-push disabled: the metadata pass leaves images_details empty...
|
||||
assert repository.scan_on_push is False
|
||||
assert repository.images_details == []
|
||||
|
||||
# ...yet the secret-scan path resolves the latest image via a dedicated
|
||||
# describe_images lookup, so the repository is not silently skipped.
|
||||
results = list(ecr._get_image_scan_data())
|
||||
|
||||
assert len(results) == 1
|
||||
fetched_repository, fetched_image, _ = results[0]
|
||||
assert fetched_repository.name == repo_name
|
||||
assert fetched_image.latest_tag == "test-tag4"
|
||||
# The dedicated lookup must NOT mutate the shared images_details, or
|
||||
# other checks would treat this repo as having a scanned image.
|
||||
assert repository.images_details == []
|
||||
|
||||
@mock_aws
|
||||
def test_get_image_scan_data_bounds_submitted_futures(self):
|
||||
"""Image fetches are submitted only as earlier results are consumed."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
for index in range(10):
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=f"{repo_name}-{index}",
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
repositories = ecr.registries[AWS_REGION_EU_WEST_1].repositories
|
||||
|
||||
executor = MagicMock()
|
||||
executor.__enter__.return_value = executor
|
||||
futures = []
|
||||
|
||||
def submit(*_args):
|
||||
future = Future()
|
||||
futures.append(future)
|
||||
if len(futures) == 1:
|
||||
future.set_result(None)
|
||||
return future
|
||||
|
||||
executor.submit.side_effect = submit
|
||||
with patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_service.ThreadPoolExecutor",
|
||||
return_value=executor,
|
||||
):
|
||||
results = ecr._get_image_scan_data()
|
||||
first_result = next(results)
|
||||
|
||||
assert first_result[0] == repositories[0]
|
||||
assert (
|
||||
first_result[1].latest_digest
|
||||
== repositories[0].images_details[-1].latest_digest
|
||||
)
|
||||
assert executor.submit.call_count == 4
|
||||
|
||||
@mock_aws
|
||||
def test_get_scan_target_image_ignores_stale_scanned_image(self):
|
||||
"""Secret scanning selects a newer image absent from scan findings."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
older_scanned_image = repository.images_details[0]
|
||||
repository.images_details = [older_scanned_image]
|
||||
|
||||
target = ecr._get_scan_target_image(repository)
|
||||
|
||||
assert target.latest_tag == "test-tag4"
|
||||
assert target.image_pushed_at > older_scanned_image.image_pushed_at
|
||||
|
||||
@mock_aws
|
||||
def test_get_scan_target_image_lookup_failure_rejects_stale_image(self):
|
||||
"""A failed authoritative lookup does not select cached scan metadata."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
repository.images_details = [repository.images_details[0]]
|
||||
|
||||
with patch.object(
|
||||
ecr.regional_clients[AWS_REGION_EU_WEST_1],
|
||||
"get_paginator",
|
||||
side_effect=RuntimeError("authoritative lookup failed"),
|
||||
):
|
||||
target = ecr._get_scan_target_image(repository)
|
||||
scan_results = list(ecr._get_image_scan_data())
|
||||
|
||||
assert isinstance(target, RuntimeError)
|
||||
assert len(scan_results) == 1
|
||||
_, result_image, result_error = scan_results[0]
|
||||
assert result_image is None and isinstance(result_error, RuntimeError)
|
||||
|
||||