mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 10:14:20 +00:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e01ce68f10 | ||
|
|
721a80921b | ||
|
|
ef62731c0a | ||
|
|
f67cf7c3e5 | ||
|
|
fcf06e148c | ||
|
|
a1e9d243be | ||
|
|
26fe612ea7 | ||
|
|
bd5afb6981 | ||
|
|
c593800e24 | ||
|
|
f1e331ad23 | ||
|
|
2f91cf430b | ||
|
|
f2d0e714c8 | ||
|
|
936d2c02a3 | ||
|
|
6a52bf432d | ||
|
|
5317c589b3 | ||
|
|
6a411881d6 | ||
|
|
6b4950f922 | ||
|
|
d6354068af | ||
|
|
4d368d7f1d | ||
|
|
1871efba93 | ||
|
|
6422178b76 | ||
|
|
587c47bfe2 | ||
|
|
13a31d9225 | ||
|
|
0715619435 | ||
|
|
1679094f22 | ||
|
|
f05a490cd7 | ||
|
|
89988dada5 | ||
|
|
0326844527 | ||
|
|
73d5c6952b | ||
|
|
ad76b3026f | ||
|
|
e21946874f | ||
|
|
2cae2058e9 | ||
|
|
c88f745038 | ||
|
|
c923c58a39 | ||
|
|
c3bee8c21e | ||
|
|
4d13e8432e | ||
|
|
5f24bec9fe | ||
|
|
afefb8f333 |
@@ -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.41.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -175,3 +175,4 @@ docker-compose.override.yml
|
||||
docker-compose-dev.override.yml
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
.gga
|
||||
|
||||
@@ -59,5 +59,9 @@ DJANGO_GITHUB_OAUTH_CLIENT_ID=""
|
||||
DJANGO_GITHUB_OAUTH_CLIENT_SECRET=""
|
||||
DJANGO_GITHUB_OAUTH_CALLBACK_URL=""
|
||||
|
||||
# Public base URL of the Prowler UI, used to link Jira issues back to findings.
|
||||
# Leave empty to omit the link.
|
||||
DJANGO_UI_BASE_URL=""
|
||||
|
||||
# Deletion Task Batch Size
|
||||
DJANGO_DELETION_BATCH_SIZE=5000
|
||||
|
||||
@@ -4,6 +4,19 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.41.0] (Prowler v5.40.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS` is now applied in months instead of days, and negative values are rejected [(#12580)](https://github.com/prowler-cloud/prowler/pull/12580)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491 [(#12509)](https://github.com/prowler-cloud/prowler/pull/12509)
|
||||
- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549)
|
||||
|
||||
---
|
||||
|
||||
## [1.40.1] (Prowler v5.39.1)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
`openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs
|
||||
@@ -0,0 +1 @@
|
||||
`POST /api/v1/providers` OpenAPI schema documents Azure certificate authentication credentials
|
||||
@@ -0,0 +1 @@
|
||||
`certificate_content` support for Azure provider secrets, with mutual exclusion against `client_secret` and certificate/private-key bundle validation
|
||||
@@ -0,0 +1 @@
|
||||
Jira issues created from Prowler Cloud now carry the `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>`, and `prowler-finding-<finding-uid>` labels, a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name
|
||||
@@ -1 +0,0 @@
|
||||
`sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491
|
||||
+1
-1
@@ -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.42.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -6,6 +6,7 @@ from api.rls import RowLevelSecurityConstraint
|
||||
from api.uuid_utils import datetime_to_uuid7
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from psqlextra.partitioning import (
|
||||
PostgresPartitioningError,
|
||||
PostgresPartitioningManager,
|
||||
@@ -153,10 +154,17 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy):
|
||||
)
|
||||
|
||||
|
||||
def relative_days_or_none(value):
|
||||
if value is None:
|
||||
def relative_months_or_none(value):
|
||||
# A negative value would set the cutoff in the future and delete every
|
||||
# partition, so it is rejected rather than silently ignored.
|
||||
if value is not None and value < 0:
|
||||
raise ImproperlyConfigured(
|
||||
"FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS must not be negative; "
|
||||
"leave it unset or use 0 to keep partitions indefinitely"
|
||||
)
|
||||
if not value:
|
||||
return None
|
||||
return relativedelta(days=value)
|
||||
return relativedelta(months=value)
|
||||
|
||||
|
||||
#
|
||||
@@ -173,7 +181,7 @@ manager = PostgresPartitioningManager(
|
||||
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
|
||||
),
|
||||
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
|
||||
max_age=relative_days_or_none(
|
||||
max_age=relative_months_or_none(
|
||||
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
|
||||
),
|
||||
name_format="%Y_%b",
|
||||
@@ -189,7 +197,7 @@ manager = PostgresPartitioningManager(
|
||||
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
|
||||
),
|
||||
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
|
||||
max_age=relative_days_or_none(
|
||||
max_age=relative_months_or_none(
|
||||
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
|
||||
),
|
||||
name_format="%Y_%b",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.41.0
|
||||
version: 1.42.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
@@ -6091,16 +6091,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: filter[updated_at__gte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: filter[updated_at__lte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: sort
|
||||
required: false
|
||||
in: query
|
||||
@@ -16312,7 +16302,7 @@ paths:
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
$ref: '#/components/schemas/UserMeResponse'
|
||||
description: ''
|
||||
components:
|
||||
schemas:
|
||||
@@ -16444,6 +16434,17 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttackPathsQueryParameter'
|
||||
outcome:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
partial:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -17680,7 +17681,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net' suffix
|
||||
(e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -17865,7 +17870,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net'
|
||||
suffix (e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -18127,7 +18136,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net' suffix
|
||||
(e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -20554,7 +20567,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net'
|
||||
suffix (e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -21272,7 +21289,7 @@ components:
|
||||
- role_arn
|
||||
- external_id
|
||||
- type: object
|
||||
title: Azure Static Credentials
|
||||
title: Azure Client Secret Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
@@ -21290,6 +21307,26 @@ components:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: Azure Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68268
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory
|
||||
where the application is registered.
|
||||
required:
|
||||
- client_id
|
||||
- certificate_content
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: M365 Static Credentials
|
||||
properties:
|
||||
@@ -21332,8 +21369,8 @@ components:
|
||||
where the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for
|
||||
certificate-based authentication.
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
@@ -21387,7 +21424,8 @@ components:
|
||||
kubeconfig_content:
|
||||
type: string
|
||||
description: The content of the Kubernetes kubeconfig file,
|
||||
encoded as a string.
|
||||
encoded as a string. Kubeconfig command-based authentication
|
||||
is not supported in Prowler Cloud for security reasons.
|
||||
required:
|
||||
- kubeconfig_content
|
||||
- type: object
|
||||
@@ -21450,18 +21488,23 @@ components:
|
||||
tenancy:
|
||||
type: string
|
||||
description: The OCID of the tenancy.
|
||||
region:
|
||||
type: string
|
||||
description: The OCI region identifier (e.g., us-ashburn-1,
|
||||
us-phoenix-1).
|
||||
pass_phrase:
|
||||
type: string
|
||||
description: The passphrase for the private key, if encrypted.
|
||||
region:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Legacy OCI region field accepted for backwards
|
||||
compatibility but ignored; OCI scans all regions.
|
||||
required:
|
||||
- user
|
||||
- fingerprint
|
||||
- tenancy
|
||||
- region
|
||||
anyOf:
|
||||
- required:
|
||||
- key_file
|
||||
- required:
|
||||
- key_content
|
||||
- type: object
|
||||
title: MongoDB Atlas API Key
|
||||
properties:
|
||||
@@ -23396,7 +23439,7 @@ components:
|
||||
- role_arn
|
||||
- external_id
|
||||
- type: object
|
||||
title: Azure Static Credentials
|
||||
title: Azure Client Secret Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
@@ -23414,6 +23457,26 @@ components:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: Azure Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68268
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory where
|
||||
the application is registered.
|
||||
required:
|
||||
- client_id
|
||||
- certificate_content
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: M365 Static Credentials
|
||||
properties:
|
||||
@@ -23510,7 +23573,8 @@ components:
|
||||
kubeconfig_content:
|
||||
type: string
|
||||
description: The content of the Kubernetes kubeconfig file, encoded
|
||||
as a string.
|
||||
as a string. Kubeconfig command-based authentication is not
|
||||
supported in Prowler Cloud for security reasons.
|
||||
required:
|
||||
- kubeconfig_content
|
||||
- type: object
|
||||
@@ -23572,17 +23636,23 @@ components:
|
||||
tenancy:
|
||||
type: string
|
||||
description: The OCID of the tenancy.
|
||||
region:
|
||||
type: string
|
||||
description: The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).
|
||||
pass_phrase:
|
||||
type: string
|
||||
description: The passphrase for the private key, if encrypted.
|
||||
region:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Legacy OCI region field accepted for backwards compatibility
|
||||
but ignored; OCI scans all regions.
|
||||
required:
|
||||
- user
|
||||
- fingerprint
|
||||
- tenancy
|
||||
- region
|
||||
anyOf:
|
||||
- required:
|
||||
- key_file
|
||||
- required:
|
||||
- key_content
|
||||
- type: object
|
||||
title: MongoDB Atlas API Key
|
||||
properties:
|
||||
@@ -23835,7 +23905,7 @@ components:
|
||||
- role_arn
|
||||
- external_id
|
||||
- type: object
|
||||
title: Azure Static Credentials
|
||||
title: Azure Client Secret Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
@@ -23853,6 +23923,26 @@ components:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: Azure Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68268
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory
|
||||
where the application is registered.
|
||||
required:
|
||||
- client_id
|
||||
- certificate_content
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: M365 Static Credentials
|
||||
properties:
|
||||
@@ -23895,8 +23985,8 @@ components:
|
||||
where the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for
|
||||
certificate-based authentication.
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
@@ -23950,7 +24040,8 @@ components:
|
||||
kubeconfig_content:
|
||||
type: string
|
||||
description: The content of the Kubernetes kubeconfig file,
|
||||
encoded as a string.
|
||||
encoded as a string. Kubeconfig command-based authentication
|
||||
is not supported in Prowler Cloud for security reasons.
|
||||
required:
|
||||
- kubeconfig_content
|
||||
- type: object
|
||||
@@ -24013,18 +24104,23 @@ components:
|
||||
tenancy:
|
||||
type: string
|
||||
description: The OCID of the tenancy.
|
||||
region:
|
||||
type: string
|
||||
description: The OCI region identifier (e.g., us-ashburn-1,
|
||||
us-phoenix-1).
|
||||
pass_phrase:
|
||||
type: string
|
||||
description: The passphrase for the private key, if encrypted.
|
||||
region:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Legacy OCI region field accepted for backwards
|
||||
compatibility but ignored; OCI scans all regions.
|
||||
required:
|
||||
- user
|
||||
- fingerprint
|
||||
- tenancy
|
||||
- region
|
||||
anyOf:
|
||||
- required:
|
||||
- key_file
|
||||
- required:
|
||||
- key_content
|
||||
- type: object
|
||||
title: MongoDB Atlas API Key
|
||||
properties:
|
||||
@@ -24297,7 +24393,7 @@ components:
|
||||
- role_arn
|
||||
- external_id
|
||||
- type: object
|
||||
title: Azure Static Credentials
|
||||
title: Azure Client Secret Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
@@ -24315,6 +24411,26 @@ components:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: Azure Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68268
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory where
|
||||
the application is registered.
|
||||
required:
|
||||
- client_id
|
||||
- certificate_content
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: M365 Static Credentials
|
||||
properties:
|
||||
@@ -24411,7 +24527,8 @@ components:
|
||||
kubeconfig_content:
|
||||
type: string
|
||||
description: The content of the Kubernetes kubeconfig file, encoded
|
||||
as a string.
|
||||
as a string. Kubeconfig command-based authentication is not
|
||||
supported in Prowler Cloud for security reasons.
|
||||
required:
|
||||
- kubeconfig_content
|
||||
- type: object
|
||||
@@ -24473,17 +24590,23 @@ components:
|
||||
tenancy:
|
||||
type: string
|
||||
description: The OCID of the tenancy.
|
||||
region:
|
||||
type: string
|
||||
description: The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).
|
||||
pass_phrase:
|
||||
type: string
|
||||
description: The passphrase for the private key, if encrypted.
|
||||
region:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Legacy OCI region field accepted for backwards compatibility
|
||||
but ignored; OCI scans all regions.
|
||||
required:
|
||||
- user
|
||||
- fingerprint
|
||||
- tenancy
|
||||
- region
|
||||
anyOf:
|
||||
- required:
|
||||
- key_file
|
||||
- required:
|
||||
- key_content
|
||||
- type: object
|
||||
title: MongoDB Atlas API Key
|
||||
properties:
|
||||
@@ -26809,6 +26932,103 @@ components:
|
||||
$ref: '#/components/schemas/UserCreate'
|
||||
required:
|
||||
- data
|
||||
UserMe:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
enum:
|
||||
- users
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 150
|
||||
minLength: 3
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
description: Case insensitive
|
||||
maxLength: 254
|
||||
company_name:
|
||||
type: string
|
||||
maxLength: 150
|
||||
date_joined:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
memberships:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- memberships
|
||||
title: Resource Type Name
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common
|
||||
attributes and relationships.
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
required:
|
||||
- data
|
||||
description: The identifier of the related object.
|
||||
title: Resource Identifier
|
||||
readOnly: true
|
||||
roles:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- roles
|
||||
title: Resource Type Name
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common
|
||||
attributes and relationships.
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
required:
|
||||
- data
|
||||
description: The identifier of the related object.
|
||||
title: Resource Identifier
|
||||
readOnly: true
|
||||
UserMeResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UserMe'
|
||||
required:
|
||||
- data
|
||||
UserResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -26849,7 +27069,6 @@ components:
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import UTC, datetime
|
||||
from itertools import islice
|
||||
|
||||
import pytest
|
||||
from api.partitions import (
|
||||
PostgresUUIDv7PartitioningStrategy,
|
||||
relative_months_or_none,
|
||||
)
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from psqlextra.partitioning import PostgresTimePartitionSize
|
||||
|
||||
|
||||
def build_strategy(max_age):
|
||||
return PostgresUUIDv7PartitioningStrategy(
|
||||
size=PostgresTimePartitionSize(months=1),
|
||||
count=1,
|
||||
start_date=datetime.now(UTC),
|
||||
max_age=max_age,
|
||||
name_format="%Y_%b",
|
||||
)
|
||||
|
||||
|
||||
class TestRelativeMonthsOrNone:
|
||||
@pytest.mark.parametrize("value", [None, 0])
|
||||
def test_unset_or_zero_keeps_partitions_indefinitely(self, value):
|
||||
assert relative_months_or_none(value) is None
|
||||
|
||||
@pytest.mark.parametrize("months", [1, 3, 12])
|
||||
def test_value_is_interpreted_as_months(self, months):
|
||||
assert relative_months_or_none(months) == relativedelta(months=months)
|
||||
|
||||
def test_value_is_not_interpreted_as_days(self):
|
||||
assert relative_months_or_none(12) != relativedelta(days=12)
|
||||
|
||||
def test_negative_is_rejected(self):
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
relative_months_or_none(-12)
|
||||
|
||||
|
||||
class TestToDelete:
|
||||
@pytest.mark.parametrize("max_age", [None, relative_months_or_none(0)])
|
||||
def test_nothing_is_deleted_without_max_age(self, max_age):
|
||||
strategy = build_strategy(max_age)
|
||||
|
||||
assert list(islice(strategy.to_delete(), 5)) == []
|
||||
|
||||
def test_first_deleted_partition_is_max_age_old(self):
|
||||
months = 3
|
||||
strategy = build_strategy(relative_months_or_none(months))
|
||||
|
||||
first = next(strategy.to_delete())
|
||||
|
||||
expected = strategy.get_start_datetime() - relativedelta(months=months)
|
||||
assert first.name() == expected.strftime("%Y_%b").lower()
|
||||
|
||||
def test_deleted_partitions_go_further_back_in_time(self):
|
||||
strategy = build_strategy(relative_months_or_none(3))
|
||||
|
||||
names = [p.name() for p in islice(strategy.to_delete(), 3)]
|
||||
starts = [datetime.strptime(n, "%Y_%b") for n in names]
|
||||
|
||||
assert starts == sorted(starts, reverse=True)
|
||||
@@ -5,6 +5,7 @@ from api.v1.serializer_utils.integrations import (
|
||||
)
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.v1.serializers import (
|
||||
AzureProviderSecret,
|
||||
ImageProviderSecret,
|
||||
IntegrationSerializer,
|
||||
IntegrationUpdateSerializer,
|
||||
@@ -198,6 +199,268 @@ class TestImageProviderSecret:
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
|
||||
class TestAzureProviderSecret:
|
||||
"""Coverage for the Azure provider secret serializer, including the
|
||||
certificate authentication path added for the Deploy-to-Azure quick-start
|
||||
(PROWLER-2378)."""
|
||||
|
||||
BASE = {
|
||||
"client_id": "87654321-4321-4321-4321-210987654321",
|
||||
"tenant_id": "12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def certificate_bundle():
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")])
|
||||
certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
bundle = certificate.public_bytes(
|
||||
serialization.Encoding.PEM
|
||||
) + private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return base64.b64encode(bundle).decode("ascii")
|
||||
|
||||
def test_accepts_client_secret_only(self):
|
||||
# Backwards-compatibility guard: rows saved by the previous serializer
|
||||
# only carry `client_secret` and must keep round-tripping cleanly.
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "client_secret": "fake-client-secret"}
|
||||
)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["client_secret"] == "fake-client-secret"
|
||||
assert "certificate_content" not in serializer.validated_data
|
||||
|
||||
def test_accepts_certificate_content_only(self):
|
||||
certificate_content = self.certificate_bundle()
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "certificate_content": certificate_content}
|
||||
)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["certificate_content"] == certificate_content
|
||||
assert "client_secret" not in serializer.validated_data
|
||||
|
||||
def test_rejects_both_client_secret_and_certificate_content(self):
|
||||
# Mutually exclusive: the backend must reject a payload carrying both
|
||||
# so the ambiguity never reaches the SDK where `certificate_content`
|
||||
# silently wins.
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"client_secret": "fake-client-secret",
|
||||
"certificate_content": self.certificate_bundle(),
|
||||
}
|
||||
)
|
||||
assert not serializer.is_valid()
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
def test_rejects_missing_secret_and_certificate(self):
|
||||
# At least one credential material must be provided.
|
||||
serializer = AzureProviderSecret(data=self.BASE)
|
||||
assert not serializer.is_valid()
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
def test_rejects_non_base64_certificate_content(self):
|
||||
# `validate_certificate_content` short-circuits obvious garbage before
|
||||
# it reaches the SDK, which would otherwise fail deep in azure-identity.
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "certificate_content": "not!valid@base64$$"}
|
||||
)
|
||||
assert not serializer.is_valid()
|
||||
assert "certificate_content" in serializer.errors
|
||||
|
||||
def test_accepts_non_uuid_tenant_and_client_ids_for_backward_compatibility(self):
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
"tenant_id": "not-a-uuid",
|
||||
"client_id": "also-not-a-uuid",
|
||||
"client_secret": "fake-client-secret",
|
||||
}
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
def test_rejects_key_only_certificate_content(self):
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
key_only_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"certificate_content": base64.b64encode(key_only_pem).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "certificate_content" in serializer.errors
|
||||
|
||||
def test_rejects_empty_strings_for_both(self):
|
||||
# DRF's CharField rejects "" at field-level before `validate()` runs.
|
||||
# The errors surface per-field rather than as non_field_errors, but
|
||||
# the important thing is that empty strings NEVER get persisted as
|
||||
# credentials.
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "client_secret": "", "certificate_content": ""}
|
||||
)
|
||||
assert not serializer.is_valid()
|
||||
assert "client_secret" in serializer.errors
|
||||
assert "certificate_content" in serializer.errors
|
||||
|
||||
def test_rejects_encrypted_pem_certificate_content(self):
|
||||
# `load_pem_private_key(password=None)` raises TypeError for
|
||||
# encrypted keys — the narrowed `except (binascii.Error, TypeError,
|
||||
# ValueError)` in the serializer must catch it and surface the
|
||||
# typed `azure-certificate-content` code rather than a 500.
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
certificate = self._self_signed_certificate()
|
||||
encrypted_key_pem = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048
|
||||
).private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(b"prowler"),
|
||||
)
|
||||
bundle = (
|
||||
certificate.public_bytes(serialization.Encoding.PEM) + encrypted_key_pem
|
||||
)
|
||||
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"certificate_content": base64.b64encode(bundle).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "certificate_content" in serializer.errors
|
||||
assert (
|
||||
serializer.errors["certificate_content"][0].code
|
||||
== "azure-certificate-content"
|
||||
)
|
||||
|
||||
def test_rejects_oversized_certificate_content(self):
|
||||
# Payloads larger than the base64 cap must be rejected inside
|
||||
# `validate_certificate_content` (before base64 decoding or bundle
|
||||
# parsing runs) so a multi-MB blob cannot exhaust API-worker
|
||||
# memory. The typed `azure-certificate-content` code lets JSON:API
|
||||
# clients recognize this as a certificate failure rather than a
|
||||
# generic length violation.
|
||||
from api.v1.serializers import _MAX_CERTIFICATE_CONTENT_LENGTH
|
||||
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"certificate_content": "A" * (_MAX_CERTIFICATE_CONTENT_LENGTH + 1),
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "certificate_content" in serializer.errors
|
||||
assert (
|
||||
serializer.errors["certificate_content"][0].code
|
||||
== "azure-certificate-content"
|
||||
)
|
||||
|
||||
def test_tolerates_whitespace_in_certificate_content(self):
|
||||
# A base64 payload with embedded whitespace (CRLF from a Windows
|
||||
# terminal, wrapped copy-paste) must not be rejected as "invalid
|
||||
# base64" — whitespace carries no information in the encoding.
|
||||
import base64
|
||||
|
||||
bundle_b64 = self.certificate_bundle()
|
||||
# Insert CRLF every 64 chars and leading/trailing spaces to mimic
|
||||
# a copy-paste from a terminal export.
|
||||
wrapped = (
|
||||
" "
|
||||
+ "\r\n".join(bundle_b64[i : i + 64] for i in range(0, len(bundle_b64), 64))
|
||||
+ " "
|
||||
)
|
||||
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "certificate_content": wrapped}
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
# The stored value is the whitespace-stripped payload, so the SDK
|
||||
# sees exactly the bytes it would from a clean base64 upload.
|
||||
assert serializer.validated_data["certificate_content"] == bundle_b64
|
||||
# And it still decodes to the original bundle unchanged.
|
||||
assert base64.b64decode(
|
||||
serializer.validated_data["certificate_content"]
|
||||
) == base64.b64decode(bundle_b64)
|
||||
|
||||
def test_mutex_errors_carry_stable_codes(self):
|
||||
# JSON:API clients key on `code`; without it they cannot tell the
|
||||
# mutex ("both provided") apart from the required-material error
|
||||
# ("neither provided") without string-matching the message.
|
||||
both = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"client_secret": "fake-client-secret",
|
||||
"certificate_content": self.certificate_bundle(),
|
||||
}
|
||||
)
|
||||
assert not both.is_valid()
|
||||
assert both.errors["non_field_errors"][0].code == "azure-credential-mutex"
|
||||
|
||||
neither = AzureProviderSecret(data=self.BASE)
|
||||
assert not neither.is_valid()
|
||||
assert neither.errors["non_field_errors"][0].code == "azure-credential-required"
|
||||
|
||||
@staticmethod
|
||||
def _self_signed_certificate():
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")])
|
||||
return (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
|
||||
class TestOracleCloudProviderSecret:
|
||||
def valid_secret(self, **overrides):
|
||||
secret = {
|
||||
@@ -244,6 +507,39 @@ class TestOracleCloudProviderSecret:
|
||||
|
||||
|
||||
class TestProviderSecretFieldSchema:
|
||||
def test_azure_schema_exposes_exclusive_supported_credential_shapes(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
azure_schemas = {
|
||||
credential_schema["title"]: credential_schema
|
||||
for credential_schema in schema["oneOf"]
|
||||
if credential_schema["title"].startswith("Azure ")
|
||||
}
|
||||
|
||||
assert set(azure_schemas) == {
|
||||
"Azure Client Secret Credentials",
|
||||
"Azure Certificate Credentials",
|
||||
}
|
||||
assert azure_schemas["Azure Client Secret Credentials"]["required"] == [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"tenant_id",
|
||||
]
|
||||
assert set(azure_schemas["Azure Client Secret Credentials"]["properties"]) == {
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"tenant_id",
|
||||
}
|
||||
assert azure_schemas["Azure Certificate Credentials"]["required"] == [
|
||||
"client_id",
|
||||
"certificate_content",
|
||||
"tenant_id",
|
||||
]
|
||||
assert set(azure_schemas["Azure Certificate Credentials"]["properties"]) == {
|
||||
"client_id",
|
||||
"certificate_content",
|
||||
"tenant_id",
|
||||
}
|
||||
|
||||
def test_oraclecloud_schema_includes_legacy_region_field(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
oraclecloud_schema = next(
|
||||
|
||||
@@ -1,14 +1,131 @@
|
||||
import socket
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from api.validators import (
|
||||
resolve_lighthouse_openai_compatible_host,
|
||||
validate_certificate_bundle,
|
||||
validate_lighthouse_openai_compatible_base_url,
|
||||
)
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import override_settings
|
||||
|
||||
|
||||
def _certificate_and_key():
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")])
|
||||
certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
return certificate, private_key
|
||||
|
||||
|
||||
def test_certificate_bundle_rejects_key_only_pkcs12():
|
||||
_, private_key = _certificate_and_key()
|
||||
key_only_pkcs12 = pkcs12.serialize_key_and_certificates(
|
||||
name=b"prowler",
|
||||
key=private_key,
|
||||
cert=None,
|
||||
cas=None,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not contain a certificate"):
|
||||
validate_certificate_bundle(key_only_pkcs12)
|
||||
|
||||
|
||||
def test_certificate_bundle_rejects_mismatched_pem_key():
|
||||
certificate, _ = _certificate_and_key()
|
||||
different_private_key = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048
|
||||
)
|
||||
mismatched_bundle = certificate.public_bytes(
|
||||
serialization.Encoding.PEM
|
||||
) + different_private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
validate_certificate_bundle(mismatched_bundle)
|
||||
|
||||
|
||||
def test_certificate_bundle_rejects_encrypted_pem_key():
|
||||
# `cryptography.load_pem_private_key(..., password=None)` raises
|
||||
# TypeError for encrypted keys; the API relies on that specific type
|
||||
# to route to `azure-certificate-content`, not a generic 500.
|
||||
certificate, _ = _certificate_and_key()
|
||||
encrypted_key_pem = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048
|
||||
).private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(b"prowler"),
|
||||
)
|
||||
encrypted_bundle = (
|
||||
certificate.public_bytes(serialization.Encoding.PEM) + encrypted_key_pem
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
validate_certificate_bundle(encrypted_bundle)
|
||||
|
||||
|
||||
def test_certificate_bundle_normalizes_multi_key_bundle_when_second_key_matches():
|
||||
# A PEM bundle may legitimately carry more than one private key block
|
||||
# (e.g. legacy tools that export both RSA and PKCS#8 encodings).
|
||||
# The validator must find the key that actually pairs with the leaf
|
||||
# instead of stopping at the first `-----BEGIN PRIVATE KEY-----`.
|
||||
leaf_cert, leaf_key = _certificate_and_key()
|
||||
_, unrelated_key = _certificate_and_key()
|
||||
|
||||
leaf_cert_pem = leaf_cert.public_bytes(serialization.Encoding.PEM)
|
||||
unrelated_key_pem = unrelated_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
leaf_key_pem = leaf_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
bundle = leaf_cert_pem + unrelated_key_pem + leaf_key_pem
|
||||
|
||||
normalized = validate_certificate_bundle(bundle)
|
||||
|
||||
# The leaf still leads (azure-identity's thumbprint invariant) and the
|
||||
# matching key is the one paired in the normalized output.
|
||||
assert normalized.startswith(leaf_cert_pem)
|
||||
assert leaf_key_pem in normalized
|
||||
|
||||
|
||||
def test_certificate_bundle_rejects_oversized_payload():
|
||||
# Legitimate PEM/PFX bundles are well under 10 KiB. Reject anything
|
||||
# above the 50 KiB cap before base64 decoding + PKCS#12/PEM parsing
|
||||
# allocate the doubled memory a multi-MB payload would need.
|
||||
from prowler.providers.azure.lib.certificate import (
|
||||
_MAX_CERTIFICATE_BUNDLE_BYTES,
|
||||
)
|
||||
|
||||
oversized = b"\x00" * (_MAX_CERTIFICATE_BUNDLE_BYTES + 1)
|
||||
|
||||
with pytest.raises(ValueError, match="maximum bundle size"):
|
||||
validate_certificate_bundle(oversized)
|
||||
|
||||
|
||||
def test_lighthouse_base_url_rejects_http_scheme():
|
||||
with pytest.raises(ValidationError, match="HTTPS"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
|
||||
@@ -3444,6 +3444,259 @@ current-context: test-context
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
@staticmethod
|
||||
def _azure_certificate_bundle_base64():
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")])
|
||||
certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
bundle = certificate.public_bytes(
|
||||
serialization.Encoding.PEM
|
||||
) + private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return base64.b64encode(bundle).decode("ascii")
|
||||
|
||||
def test_provider_secrets_create_azure_persists_whitespace_stripped_certificate(
|
||||
self,
|
||||
authenticated_client,
|
||||
azure_provider,
|
||||
):
|
||||
# `AzureProviderSecret.validate_certificate_content` strips whitespace
|
||||
# inside the base64 payload. The outer write path must reassign the
|
||||
# validator's normalized output so the value that reaches Fernet
|
||||
# storage matches what the SDK will later decode.
|
||||
clean_b64 = self._azure_certificate_bundle_base64()
|
||||
wrapped_b64 = (
|
||||
" "
|
||||
+ "\r\n".join(clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64))
|
||||
+ " "
|
||||
)
|
||||
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "Azure Cert Secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"client_id": "87654321-4321-4321-4321-210987654321",
|
||||
"tenant_id": "12345678-1234-1234-1234-123456789012",
|
||||
"certificate_content": wrapped_b64,
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {"type": "providers", "id": str(azure_provider.id)}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, response.content
|
||||
provider_secret = ProviderSecret.objects.get(id=response.json()["data"]["id"])
|
||||
# Persisted value is the whitespace-stripped payload — proves the
|
||||
# per-provider validator's return value is what Fernet encrypted,
|
||||
# not the raw upload.
|
||||
assert provider_secret.secret["certificate_content"] == clean_b64
|
||||
|
||||
def test_provider_secrets_update_azure_persists_whitespace_stripped_certificate(
|
||||
self,
|
||||
authenticated_client,
|
||||
azure_provider,
|
||||
):
|
||||
create_response = authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "Azure Cert Secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"client_id": "87654321-4321-4321-4321-210987654321",
|
||||
"tenant_id": "12345678-1234-1234-1234-123456789012",
|
||||
"certificate_content": self._azure_certificate_bundle_base64(),
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(azure_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
|
||||
clean_b64 = self._azure_certificate_bundle_base64()
|
||||
wrapped_b64 = "\r\n".join(
|
||||
clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64)
|
||||
)
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {
|
||||
"secret": {
|
||||
"client_id": "87654321-4321-4321-4321-210987654321",
|
||||
"tenant_id": "12345678-1234-1234-1234-123456789012",
|
||||
"certificate_content": wrapped_b64,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, response.content
|
||||
provider_secret.refresh_from_db()
|
||||
assert provider_secret.secret["certificate_content"] == clean_b64
|
||||
|
||||
def test_provider_secrets_create_m365_preserves_whitespace_in_client_secret(
|
||||
self,
|
||||
authenticated_client,
|
||||
m365_provider,
|
||||
):
|
||||
# DRF `CharField.trim_whitespace` (the default) would silently strip
|
||||
# surrounding whitespace off opaque credentials if the outer write
|
||||
# path blindly persisted the validated dict. Legitimate M365 client
|
||||
# secrets can contain leading/trailing whitespace, so the raw
|
||||
# submitted value must survive the round-trip unchanged.
|
||||
whitespace_secret = " M365-Secret-With-Padding "
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "M365 Secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"client_id": "87654321-4321-4321-4321-210987654321",
|
||||
"tenant_id": "12345678-1234-1234-1234-123456789012",
|
||||
"client_secret": whitespace_secret,
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {"type": "providers", "id": str(m365_provider.id)}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, response.content
|
||||
provider_secret = ProviderSecret.objects.get(id=response.json()["data"]["id"])
|
||||
assert provider_secret.secret["client_secret"] == whitespace_secret
|
||||
|
||||
def test_provider_secrets_update_image_preserves_whitespace_in_registry_password(
|
||||
self,
|
||||
authenticated_client,
|
||||
image_provider,
|
||||
):
|
||||
# Container-registry passwords can be opaque high-entropy strings
|
||||
# generated by tooling that includes trailing whitespace as part of
|
||||
# the credential. The outer write path must keep the submitted
|
||||
# value verbatim so the registry still accepts it after a PATCH.
|
||||
create_response = authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "Registry Secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"registry_username": "prowler",
|
||||
"registry_password": "initial-password",
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(image_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
whitespace_password = " registry-password-with-padding "
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {
|
||||
"secret": {
|
||||
"registry_username": "prowler",
|
||||
"registry_password": whitespace_password,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, response.content
|
||||
provider_secret.refresh_from_db()
|
||||
assert provider_secret.secret["registry_password"] == whitespace_password
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes, error_code, error_pointer",
|
||||
(
|
||||
|
||||
@@ -78,7 +78,7 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Azure Static Credentials",
|
||||
"title": "Azure Client Secret Credentials",
|
||||
"properties": {
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
@@ -97,6 +97,26 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
"required": ["client_id", "client_secret", "tenant_id"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Azure Certificate Credentials",
|
||||
"properties": {
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"description": "The Azure application (client) ID for authentication in Azure AD.",
|
||||
},
|
||||
"certificate_content": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication.",
|
||||
},
|
||||
"tenant_id": {
|
||||
"type": "string",
|
||||
"description": "The Azure tenant ID, representing the directory where the application is "
|
||||
"registered.",
|
||||
},
|
||||
},
|
||||
"required": ["client_id", "certificate_content", "tenant_id"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "M365 Static Credentials",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -60,7 +61,10 @@ from api.v1.serializer_utils.lighthouse import (
|
||||
)
|
||||
from api.v1.serializer_utils.processors import ProcessorConfigField
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.validators import validate_lighthouse_openai_compatible_base_url
|
||||
from api.validators import (
|
||||
validate_certificate_bundle,
|
||||
validate_lighthouse_openai_compatible_base_url,
|
||||
)
|
||||
from config.custom_logging import BackendLogger
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
@@ -1785,10 +1789,77 @@ class AwsProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
# Base64 cap that matches the SDK's 50 KiB `_MAX_CERTIFICATE_BUNDLE_BYTES`
|
||||
# limit on the decoded bundle. Rejects oversized payloads at the request
|
||||
# layer so DRF never allocates the doubled memory that base64 decoding plus
|
||||
# PKCS#12/PEM parsing would need for a multi-MB blob.
|
||||
# `((51200 + 2) // 3) * 4` = 68268 base64 chars for a bundle exactly at the
|
||||
# SDK cap. Keep the two constants aligned if either side moves.
|
||||
_MAX_CERTIFICATE_CONTENT_LENGTH = 68268
|
||||
|
||||
|
||||
class AzureProviderSecret(serializers.Serializer):
|
||||
client_id = serializers.CharField()
|
||||
client_secret = serializers.CharField()
|
||||
client_secret = serializers.CharField(required=False)
|
||||
tenant_id = serializers.CharField()
|
||||
# The size cap is enforced in `validate_certificate_content` with the
|
||||
# typed `azure-certificate-content` code, not via `max_length=`. DRF's
|
||||
# built-in max-length check runs before per-field validators and emits
|
||||
# `code="max_length"`, which JSON:API clients keyed to the typed
|
||||
# certificate error code cannot recognize as a certificate failure.
|
||||
certificate_content = serializers.CharField(required=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get("client_secret") and attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You cannot provide both client_secret and certificate_content.",
|
||||
code="azure-credential-mutex",
|
||||
)
|
||||
if not attrs.get("client_secret") and not attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You must provide either client_secret or certificate_content.",
|
||||
code="azure-credential-required",
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
def validate_certificate_content(self, certificate_content):
|
||||
"""Validate the Azure certificate and matching private-key bundle."""
|
||||
if certificate_content:
|
||||
# Tolerate whitespace inside the base64 payload: exports from
|
||||
# Windows terminals (CRLF) or wrapped copy-paste survive without
|
||||
# tripping `base64.b64decode(validate=True)`, and the reader is
|
||||
# base64 anyway — internal whitespace carries no information.
|
||||
certificate_content = "".join(certificate_content.split())
|
||||
if len(certificate_content) > _MAX_CERTIFICATE_CONTENT_LENGTH:
|
||||
# Reject oversized payloads with the typed certificate code
|
||||
# so JSON:API clients recognize this as a certificate-content
|
||||
# failure rather than a generic length violation.
|
||||
raise serializers.ValidationError(
|
||||
"Certificate content exceeds the maximum size.",
|
||||
code="azure-certificate-content",
|
||||
)
|
||||
try:
|
||||
certificate_data = base64.b64decode(certificate_content, validate=True)
|
||||
validate_certificate_bundle(certificate_data)
|
||||
# `binascii.Error` (bad base64), `TypeError` (encrypted PEM key)
|
||||
# and `ValueError` (mismatched cert/key, oversized bundle,
|
||||
# malformed bytes) are the failure modes `validate_certificate_bundle`
|
||||
# and `base64.b64decode` surface. Anything else is a real bug
|
||||
# and should propagate.
|
||||
except (binascii.Error, TypeError, ValueError) as e:
|
||||
logger.error(
|
||||
f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}"
|
||||
)
|
||||
# Field validators are invoked per-field; DRF already knows
|
||||
# this error belongs to `certificate_content` and will nest
|
||||
# the message under that key. Raising a dict here would
|
||||
# double-nest the JSON:API pointer as
|
||||
# `/certificate_content/certificate_content`.
|
||||
raise serializers.ValidationError(
|
||||
"Certificate content must be valid base64 containing an X.509 certificate and its matching private key.",
|
||||
code="azure-certificate-content",
|
||||
) from e
|
||||
return certificate_content
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
@@ -2092,8 +2163,24 @@ class ProviderSecretCreateSerializer(
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
# OCI persists the full validated dict on purpose (its serializer
|
||||
# already performs the sanitization it wants). For Azure, only the
|
||||
# `certificate_content` field must be replaced with the normalized
|
||||
# (whitespace-stripped) value; the rest of the dict is left as the
|
||||
# caller submitted it so opaque credentials elsewhere in the payload
|
||||
# keep whatever whitespace they carried. Every other provider stays
|
||||
# on the outer JSONField's raw dict — DRF's `CharField.trim_whitespace`
|
||||
# would otherwise silently mutate opaque tokens/passwords.
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
elif (
|
||||
provider.provider == Provider.ProviderChoices.AZURE.value
|
||||
and validated_secret.get("certificate_content")
|
||||
):
|
||||
validated_attrs["secret"] = {
|
||||
**secret,
|
||||
"certificate_content": validated_secret["certificate_content"],
|
||||
}
|
||||
return validated_attrs
|
||||
|
||||
|
||||
@@ -2128,8 +2215,21 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
# Same targeted persistence as `ProviderSecretCreateSerializer.validate`:
|
||||
# OCI keeps its full validated dict, Azure replaces only
|
||||
# `certificate_content`, and every other provider stays on the raw
|
||||
# submitted dict so opaque credentials do not get their whitespace
|
||||
# silently trimmed.
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
elif (
|
||||
provider.provider == Provider.ProviderChoices.AZURE.value
|
||||
and validated_secret.get("certificate_content")
|
||||
):
|
||||
validated_attrs["secret"] = {
|
||||
**secret,
|
||||
"certificate_content": validated_secret["certificate_content"],
|
||||
}
|
||||
return validated_attrs
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
# Re-exported so the SDK stays the single source of truth for bundle parsing:
|
||||
# it covers PKCS#12 UnsupportedAlgorithm, encrypted PEM keys (TypeError), the
|
||||
# full private-key PEM label set, and leaf-first normalization for
|
||||
# azure-identity's thumbprint. A local copy silently drifted before.
|
||||
from prowler.providers.azure.lib.certificate import ( # noqa: F401
|
||||
validate_certificate_bundle,
|
||||
)
|
||||
|
||||
LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES = frozenset({"https"})
|
||||
LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")
|
||||
LIGHTHOUSE_BLOCKED_METADATA_HOSTS = frozenset(
|
||||
|
||||
@@ -303,6 +303,11 @@ SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
|
||||
DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000)
|
||||
|
||||
# Public base URL of the Prowler UI (for example https://cloud.prowler.com). Used to
|
||||
# build links back to findings in outbound integrations such as Jira. Empty by
|
||||
# default, so self-hosted deployments emit no links unless they configure it.
|
||||
UI_BASE_URL = env.str("DJANGO_UI_BASE_URL", "").rstrip("/")
|
||||
|
||||
# SAML requirement
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
@@ -2,13 +2,16 @@ import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from glob import glob
|
||||
from urllib.parse import quote
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction
|
||||
from api.models import Finding, Integration, Provider
|
||||
from api.rls import Tenant
|
||||
from api.utils import initialize_prowler_integration, initialize_prowler_provider
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from django.conf import settings
|
||||
from django.db import OperationalError
|
||||
from prowler.lib.outputs.asff.asff import ASFF
|
||||
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
@@ -16,6 +19,7 @@ from prowler.lib.outputs.csv.csv import CSV
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
from prowler.lib.outputs.html.html import HTML
|
||||
from prowler.lib.outputs.jira.exceptions.exceptions import JiraBaseException
|
||||
from prowler.lib.outputs.jira.jira import Jira
|
||||
from prowler.lib.outputs.ocsf.ocsf import OCSF
|
||||
from prowler.providers.aws.aws_provider import AwsProvider
|
||||
from prowler.providers.aws.lib.s3.s3 import S3
|
||||
@@ -477,6 +481,55 @@ def upload_security_hub_integration(
|
||||
return False
|
||||
|
||||
|
||||
JIRA_LABEL_PREFIX = "prowler"
|
||||
|
||||
|
||||
def build_jira_finding_url(finding_uid: str) -> str:
|
||||
"""Build the Prowler UI link for a finding, or "" when no UI base URL is set.
|
||||
|
||||
The link filters by the finding ``uid`` rather than the per-scan record id so
|
||||
it keeps resolving after the finding is seen again in later scans.
|
||||
"""
|
||||
base_url = getattr(settings, "UI_BASE_URL", "")
|
||||
if not base_url or not finding_uid:
|
||||
return ""
|
||||
return f"{base_url}/findings?filter[uid]={quote(finding_uid, safe='')}"
|
||||
|
||||
|
||||
def build_jira_issue_labels(
|
||||
finding_uid: str, provider: str, severity: str, check_id: str
|
||||
) -> list[str]:
|
||||
"""Build the deterministic label set written to every Jira issue.
|
||||
|
||||
Labels are prefixed to avoid colliding with customer labels and sanitized so
|
||||
Jira never rejects them; the finding-uid label is what lets a ticket be traced
|
||||
back (or JQL-filtered) to its finding.
|
||||
"""
|
||||
raw_labels = [
|
||||
JIRA_LABEL_PREFIX,
|
||||
f"{JIRA_LABEL_PREFIX}-{provider}" if provider else "",
|
||||
f"{JIRA_LABEL_PREFIX}-{severity}" if severity else "",
|
||||
f"{JIRA_LABEL_PREFIX}-{check_id}" if check_id else "",
|
||||
Jira.build_finding_label(finding_uid),
|
||||
]
|
||||
return Jira.sanitize_labels(raw_labels)
|
||||
|
||||
|
||||
def get_tenant_name(tenant_id: str) -> str:
|
||||
"""Return the tenant name for the Jira issue "Tenant Info" row, or "" if unknown.
|
||||
|
||||
The name is informational only, so a lookup failure must never block the send.
|
||||
"""
|
||||
try:
|
||||
return (
|
||||
Tenant.objects.filter(id=tenant_id).values_list("name", flat=True).first()
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Could not resolve tenant name for %s", tenant_id)
|
||||
return ""
|
||||
|
||||
|
||||
def send_findings_to_jira(
|
||||
tenant_id: str,
|
||||
integration_id: str,
|
||||
@@ -487,6 +540,7 @@ def send_findings_to_jira(
|
||||
with rls_transaction(tenant_id):
|
||||
integration = Integration.objects.get(id=integration_id)
|
||||
jira_integration = initialize_prowler_integration(integration)
|
||||
tenant_info = get_tenant_name(tenant_id)
|
||||
|
||||
num_tickets_created = 0
|
||||
error_messages = []
|
||||
@@ -519,6 +573,15 @@ def send_findings_to_jira(
|
||||
recommendation = remediation.get("recommendation", {})
|
||||
remediation_code = remediation.get("code", {})
|
||||
|
||||
provider_type = finding_instance.scan.provider.provider
|
||||
issue_labels = build_jira_issue_labels(
|
||||
finding_uid=finding_instance.uid,
|
||||
provider=provider_type,
|
||||
severity=finding_instance.severity,
|
||||
check_id=finding_instance.check_id,
|
||||
)
|
||||
finding_url = build_jira_finding_url(finding_instance.uid)
|
||||
|
||||
try:
|
||||
# Send the individual finding to Jira
|
||||
result = jira_integration.send_finding(
|
||||
@@ -527,7 +590,7 @@ def send_findings_to_jira(
|
||||
severity=finding_instance.severity,
|
||||
status=finding_instance.status,
|
||||
status_extended=finding_instance.status_extended or "",
|
||||
provider=finding_instance.scan.provider.provider,
|
||||
provider=provider_type,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
@@ -542,6 +605,9 @@ def send_findings_to_jira(
|
||||
compliance=finding_instance.compliance or {},
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
issue_labels=issue_labels,
|
||||
finding_url=finding_url,
|
||||
tenant_info=tenant_info,
|
||||
)
|
||||
except JiraBaseException as error:
|
||||
error_message = error.message or JIRA_GENERIC_SEND_ERROR
|
||||
@@ -557,6 +623,11 @@ def send_findings_to_jira(
|
||||
|
||||
if result:
|
||||
num_tickets_created += 1
|
||||
logger.info(
|
||||
"Finding %s sent to Jira as %s",
|
||||
finding_id,
|
||||
result.get("key") if isinstance(result, dict) else result,
|
||||
)
|
||||
else:
|
||||
error_message = JIRA_GENERIC_SEND_ERROR
|
||||
logger.error(error_message)
|
||||
|
||||
@@ -6,15 +6,20 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.models import Integration
|
||||
from api.utils import prowler_integration_connection_test
|
||||
from django.db import OperationalError
|
||||
from django.test import override_settings
|
||||
from prowler.lib.outputs.jira.exceptions.exceptions import (
|
||||
JiraRefreshTokenError,
|
||||
JiraRequiredCustomFieldsError,
|
||||
)
|
||||
from prowler.lib.outputs.jira.jira import Jira
|
||||
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
|
||||
from prowler.providers.common.models import Connection
|
||||
from tasks.jobs.integrations import (
|
||||
build_jira_finding_url,
|
||||
build_jira_issue_labels,
|
||||
get_s3_client_from_integration,
|
||||
get_security_hub_client_from_integration,
|
||||
get_tenant_name,
|
||||
send_findings_to_jira,
|
||||
upload_s3_integration,
|
||||
upload_security_hub_integration,
|
||||
@@ -1696,6 +1701,7 @@ class TestJiraIntegration:
|
||||
|
||||
finding1 = MagicMock()
|
||||
finding1.id = "finding-1"
|
||||
finding1.uid = "prowler-aws-check_001-123456789012-us-east-1-my bucket"
|
||||
finding1.check_id = "check_001"
|
||||
finding1.severity = "high"
|
||||
finding1.status = "FAIL"
|
||||
@@ -1724,6 +1730,7 @@ class TestJiraIntegration:
|
||||
|
||||
finding2 = MagicMock()
|
||||
finding2.id = "finding-2"
|
||||
finding2.uid = "prowler-azure-check_002-sub/resource"
|
||||
finding2.check_id = "check_002"
|
||||
finding2.severity = "medium"
|
||||
finding2.status = "PASS"
|
||||
@@ -1748,9 +1755,13 @@ class TestJiraIntegration:
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
with (
|
||||
override_settings(UI_BASE_URL="https://cloud.example.com"),
|
||||
patch("tasks.jobs.integrations.get_tenant_name", return_value="Acme"),
|
||||
):
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 2, "failed_count": 0}
|
||||
@@ -1773,12 +1784,36 @@ class TestJiraIntegration:
|
||||
assert first_call.kwargs["provider"] == "aws"
|
||||
assert first_call.kwargs["project_key"] == project_key
|
||||
assert first_call.kwargs["issue_type"] == issue_type
|
||||
# Finding reference: labels, link back and tenant info
|
||||
assert first_call.kwargs["issue_labels"] == [
|
||||
"prowler",
|
||||
"prowler-aws",
|
||||
"prowler-high",
|
||||
"prowler-check_001",
|
||||
"prowler-finding-prowler-aws-check_001-123456789012-us-east-1-my_bucket",
|
||||
]
|
||||
assert first_call.kwargs["finding_url"] == (
|
||||
"https://cloud.example.com/findings?filter[uid]="
|
||||
"prowler-aws-check_001-123456789012-us-east-1-my%20bucket"
|
||||
)
|
||||
assert first_call.kwargs["tenant_info"] == "Acme"
|
||||
|
||||
# Verify second call
|
||||
second_call = mock_jira_integration.send_finding.call_args_list[1]
|
||||
assert second_call.kwargs["check_id"] == "check_002"
|
||||
assert second_call.kwargs["severity"] == "medium"
|
||||
assert second_call.kwargs["status"] == "PASS"
|
||||
assert second_call.kwargs["issue_labels"] == [
|
||||
"prowler",
|
||||
"prowler-azure",
|
||||
"prowler-medium",
|
||||
"prowler-check_002",
|
||||
"prowler-finding-prowler-azure-check_002-sub/resource",
|
||||
]
|
||||
assert second_call.kwargs["finding_url"] == (
|
||||
"https://cloud.example.com/findings?filter[uid]="
|
||||
"prowler-azure-check_002-sub%2Fresource"
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@@ -2200,3 +2235,101 @@ class TestJiraIntegration:
|
||||
assert call_kwargs["remediation_code_cli"] == ""
|
||||
assert call_kwargs["remediation_code_other"] == ""
|
||||
assert call_kwargs["compliance"] == {}
|
||||
|
||||
|
||||
class TestJiraFindingReference:
|
||||
"""Helpers that give Jira issues a stable reference back to the finding."""
|
||||
|
||||
def test_build_jira_issue_labels(self):
|
||||
assert build_jira_issue_labels(
|
||||
finding_uid="prowler-aws-check-123-eu-west-1-hub/unknown",
|
||||
provider="aws",
|
||||
severity="critical",
|
||||
check_id="iam_root_mfa",
|
||||
) == [
|
||||
"prowler",
|
||||
"prowler-aws",
|
||||
"prowler-critical",
|
||||
"prowler-iam_root_mfa",
|
||||
"prowler-finding-prowler-aws-check-123-eu-west-1-hub/unknown",
|
||||
]
|
||||
|
||||
def test_build_jira_issue_labels_skips_empty_parts(self):
|
||||
assert build_jira_issue_labels(
|
||||
finding_uid="", provider="", severity="", check_id=""
|
||||
) == ["prowler"]
|
||||
|
||||
def test_build_jira_issue_labels_sanitizes_metadata(self):
|
||||
assert build_jira_issue_labels(
|
||||
finding_uid=" uid\x00 with spaces ",
|
||||
provider="aws cloud",
|
||||
severity="high severity",
|
||||
check_id="check id",
|
||||
) == [
|
||||
"prowler",
|
||||
"prowler-aws_cloud",
|
||||
"prowler-high_severity",
|
||||
"prowler-check_id",
|
||||
"prowler-finding-uid_with_spaces",
|
||||
]
|
||||
|
||||
def test_build_jira_issue_labels_preserves_maximum_length_uid(self):
|
||||
finding_uid = "u" * (Jira.LABEL_MAX_LENGTH - len(Jira.FINDING_LABEL_PREFIX) - 1)
|
||||
finding_label = build_jira_issue_labels(
|
||||
finding_uid=finding_uid,
|
||||
provider="gcp",
|
||||
severity="low",
|
||||
check_id="check",
|
||||
)[-1]
|
||||
|
||||
assert finding_label == f"{Jira.FINDING_LABEL_PREFIX}-{finding_uid}"
|
||||
assert len(finding_label) == Jira.LABEL_MAX_LENGTH
|
||||
|
||||
def test_build_jira_issue_labels_distinguishes_long_uids(self):
|
||||
common_prefix = "u" * 300
|
||||
first_uid = f"{common_prefix}-first"
|
||||
second_uid = f"{common_prefix}-second"
|
||||
|
||||
first_label = build_jira_issue_labels(
|
||||
finding_uid=first_uid,
|
||||
provider="gcp",
|
||||
severity="low",
|
||||
check_id="check",
|
||||
)[-1]
|
||||
second_label = build_jira_issue_labels(
|
||||
finding_uid=second_uid,
|
||||
provider="gcp",
|
||||
severity="low",
|
||||
check_id="check",
|
||||
)[-1]
|
||||
|
||||
assert first_label == Jira.build_finding_label(first_uid)
|
||||
assert second_label == Jira.build_finding_label(second_uid)
|
||||
assert first_label != second_label
|
||||
assert len(first_label) == Jira.LABEL_MAX_LENGTH
|
||||
assert len(second_label) == Jira.LABEL_MAX_LENGTH
|
||||
|
||||
@override_settings(UI_BASE_URL="")
|
||||
def test_build_jira_finding_url_without_base_url(self):
|
||||
assert build_jira_finding_url("prowler-aws-check-1") == ""
|
||||
|
||||
@override_settings(UI_BASE_URL="https://cloud.example.com")
|
||||
def test_build_jira_finding_url_with_base_url(self):
|
||||
assert build_jira_finding_url("prowler-aws-check-1") == (
|
||||
"https://cloud.example.com/findings?filter[uid]=prowler-aws-check-1"
|
||||
)
|
||||
# uid characters that would break the query string are encoded
|
||||
assert build_jira_finding_url("a/b c&d") == (
|
||||
"https://cloud.example.com/findings?filter[uid]=a%2Fb%20c%26d"
|
||||
)
|
||||
assert build_jira_finding_url("") == ""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_tenant_name(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
assert get_tenant_name(str(tenant.id)) == tenant.name
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_tenant_name_unknown_or_invalid(self):
|
||||
assert get_tenant_name("00000000-0000-0000-0000-000000000000") == ""
|
||||
assert get_tenant_name("not-a-uuid") == ""
|
||||
|
||||
Generated
+72
-3
@@ -4835,8 +4835,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
|
||||
version = "5.41.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4928,14 +4928,17 @@ dependencies = [
|
||||
{ name = "stackit-iaas" },
|
||||
{ name = "stackit-objectstorage" },
|
||||
{ name = "stackit-resourcemanager" },
|
||||
{ name = "stackit-ske" },
|
||||
{ name = "tabulate" },
|
||||
{ name = "truststore" },
|
||||
{ name = "tzlocal" },
|
||||
{ name = "uuid6" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.41.0"
|
||||
version = "1.42.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -6117,6 +6120,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-ske"
|
||||
version = "1.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "stackit-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/9e/df3ad585cb96d028354f4253568e9879d81bb9395d5ebfa268fa9350e2df/stackit_ske-1.12.0.tar.gz", hash = "sha256:62814279f3b7fb2387648f92d14453a8905ad60115c07579f2741ddb7d1fcc94", size = 37239, upload-time = "2026-06-30T11:18:49.39Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/37/dc54fb7185a2d4da37308322ea1a7b992312030b2e37262de4eb4003f5c7/stackit_ske-1.12.0-py3-none-any.whl", hash = "sha256:45bd8084d87f14f818b3d7e824450248c8784ed204ca1b2dc108f491dcbdb1a3", size = 93142, upload-time = "2026-06-30T11:18:48.233Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "statsd"
|
||||
version = "4.0.1"
|
||||
@@ -6225,6 +6243,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truststore"
|
||||
version = "0.10.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.21.1"
|
||||
@@ -6621,6 +6648,48 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.2"
|
||||
|
||||
+134
-1
@@ -4,6 +4,139 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.40.0" description="August 28, 2026">
|
||||
### 💬 Slack Integration — Alert Channel Destinations
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Alerts can now reach Slack. Connect a Slack workspace from the Integrations page, authorize one or several destination channels (the connection check confirms each channel with a one-time message and names any channel Slack refuses), and pick those channels in the alert modal's "Destination channels" selector, next to the "Recipients" selector for email. The alerts list summarizes both in a single "Destinations" column, showing a rule's email recipients and Slack channels at a glance. Disconnecting the workspace and recovering from revoked credentials are handled from the same page.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Read more in the [Slack integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration) and the [Alerts documentation](https://docs.prowler.com/user-guide/tutorials/prowler-alerts).
|
||||
|
||||
### 🤖 Lighthouse AI — Answer Feedback
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Every Lighthouse AI answer can now be rated with a thumbs up or thumbs down, with an optional field to describe what worked or what did not. Feedback is collected per answer, directly in the chat, and tells the team where Lighthouse should improve next.
|
||||
|
||||
Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse).
|
||||
|
||||
### 📥 Providers — Imported Findings Indicator
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Providers whose findings were imported with the Prowler CLI now show an "Imported provider" indicator next to their connection status in the providers table. In accounts that mix connected providers with Import Findings uploads, the table now tells them apart at a glance.
|
||||
|
||||

|
||||
|
||||
Read more in the [Import Findings documentation](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings).
|
||||
|
||||
### 📚 Compliance — NCSC Cyber Essentials 3.3
|
||||
|
||||
Cyber Essentials is the UK National Cyber Security Centre (NCSC) scheme certifying the baseline technical controls an organization must implement, and cloud services are explicitly in scope and cannot be excluded from an assessment. Prowler now includes NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026) as a universal framework, with its 28 requirements organized in the five control themes: Firewalls, Secure Configuration, Security Update Management, User Access Control, and Malware Protection.
|
||||
|
||||
Sixteen requirements map to Azure checks covering the controls the applicant organization owns under the shared responsibility model. The remaining twelve apply to end-user devices, on-premises network appliances, or organizational process, which cloud control-plane evidence cannot observe, so they are reported as Manual.
|
||||
|
||||
Contributed by @m-khan-97. Thank you!
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
Fifteen new checks land across seven providers in this release.
|
||||
|
||||
#### AWS
|
||||
|
||||
- `ecr_repository_image_no_secrets` scans the latest image of each ECR repository, both its configuration and its filesystem layers, for hardcoded secrets. Thanks to @esquaredsec!
|
||||
- Four new Amazon Bedrock checks, thanks to @tamg-aws!
|
||||
- `bedrock_guardrail_contextual_grounding_filter_enabled` verifies that guardrails enable both contextual grounding filters, blocking responses that are not supported by the retrieved source or do not answer the question asked.
|
||||
- `bedrock_custom_model_encrypted_with_cmk` verifies that custom models are encrypted at rest with a customer-managed KMS key instead of an AWS-owned key the organization cannot audit, rotate, or revoke.
|
||||
- `bedrock_knowledge_base_encrypted_with_cmk` verifies that each knowledge-base data source encrypts with a customer-managed KMS key the transient storage used while documents are chunked and embedded.
|
||||
- `bedrock_agent_role_not_shared_across_agents` verifies that every agent has a dedicated execution role, so no agent inherits another's permissions.
|
||||
- `rolesanywhere_profile_restricts_session_permissions` flags IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies.
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
#### GCP
|
||||
|
||||
- `iam_workload_identity_pool_provider_attribute_condition` flags Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals.
|
||||
|
||||
Explore all GCP checks at [Prowler Hub](https://hub.prowler.com/check?provider=gcp).
|
||||
|
||||
#### GitHub
|
||||
|
||||
Three new checks harden GitHub Actions defaults, all contributed by @Edneam. Thank you!
|
||||
|
||||
- `organization_default_workflow_permissions_read_only` and `repository_default_workflow_permissions_read_only` verify that workflows get a read-only default `GITHUB_TOKEN` at the organization and repository level.
|
||||
- `organization_actions_pull_request_approval_disabled` verifies that organizations prevent GitHub Actions from creating and approving pull requests.
|
||||
|
||||
Explore all GitHub checks at [Prowler Hub](https://hub.prowler.com/check?provider=github).
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
- `defender_domain_dmarc_records_published` checks that every Exchange Online domain publishes a DMARC record with an enforcing policy (`p=quarantine` or `p=reject`). Thanks to @Rishi943!
|
||||
|
||||
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
- `oss_bucket_versioning_enabled` verifies that OSS buckets keep versioning enabled, allowing recovery from accidental or malicious object overwrite and deletion. Thanks to @abidedavana!
|
||||
- `oss_bucket_server_side_encryption_enabled` verifies that OSS buckets define a default server-side encryption rule, either AES256 or KMS. Thanks to @alexchen-sys!
|
||||
|
||||
OSS bucket logging, versioning, default encryption, and ACL configurations are also now read correctly from the Alibaba Cloud SDK, so the checks reading them no longer report every bucket as unconfigured.
|
||||
|
||||
Explore all Alibaba Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=alibabacloud).
|
||||
|
||||
#### Huawei Cloud
|
||||
|
||||
- `vpc_security_group_open_egress` flags VPC security groups that allow open egress to the internet. Thanks to @tomitobio!
|
||||
|
||||
Explore all Huawei Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=huaweicloud).
|
||||
|
||||
#### STACKIT
|
||||
|
||||
- `ske_cluster_no_public_endpoint` flags 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`. Thanks to @johannes-engler-mw!
|
||||
|
||||
Explore all STACKIT checks at [Prowler Hub](https://hub.prowler.com/check?provider=stackit).
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- The API and SDK container images upgrade OpenSSL to 3.5.7-1~deb13u2, patching ten high CVEs; the UI image upgrades `libcrypto3` and `libssl3` to 3.5.8-r0, patching seven high CVEs; the MCP Server image patches CVE-2026-14456 (OpenSSL), CVE-2026-11822, and CVE-2026-11824 (SQLite).
|
||||
- `sqlparse` upgraded to 0.6.0 in the API, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491.
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @Edneam: GitHub `organization_default_workflow_permissions_read_only` ([#12122](https://github.com/prowler-cloud/prowler/pull/12122)), `repository_default_workflow_permissions_read_only` ([#12143](https://github.com/prowler-cloud/prowler/pull/12143)), and `organization_actions_pull_request_approval_disabled` ([#12394](https://github.com/prowler-cloud/prowler/pull/12394)) checks
|
||||
- @tamg-aws: four AWS Bedrock checks covering guardrail grounding, CMK encryption, and agent role isolation ([#12459](https://github.com/prowler-cloud/prowler/pull/12459))
|
||||
- @esquaredsec: AWS `ecr_repository_image_no_secrets` check ([#12123](https://github.com/prowler-cloud/prowler/pull/12123))
|
||||
- @Rishi943: Microsoft 365 `defender_domain_dmarc_records_published` check ([#11936](https://github.com/prowler-cloud/prowler/pull/11936))
|
||||
- @abidedavana: Alibaba Cloud `oss_bucket_versioning_enabled` check ([#11913](https://github.com/prowler-cloud/prowler/pull/11913))
|
||||
- @alexchen-sys: Alibaba Cloud `oss_bucket_server_side_encryption_enabled` check ([#11981](https://github.com/prowler-cloud/prowler/pull/11981))
|
||||
- @tomitobio: Huawei Cloud `vpc_security_group_open_egress` check ([#12209](https://github.com/prowler-cloud/prowler/pull/12209))
|
||||
- @johannes-engler-mw: STACKIT `ske_cluster_no_public_endpoint` check ([#11943](https://github.com/prowler-cloud/prowler/pull/11943))
|
||||
- @gabrielfrdev: cluster name in Kubernetes compliance report outputs ([#12506](https://github.com/prowler-cloud/prowler/pull/12506))
|
||||
- @jfgmesquita: AWS FSBP compliance mapping fix for IAM.9 and EKS.1 ([#12372](https://github.com/prowler-cloud/prowler/pull/12372))
|
||||
- @hackertwinten: `ec2_securitygroup_not_used` no longer flags security groups held only by scaled-down AWS Batch compute environments ([#12458](https://github.com/prowler-cloud/prowler/pull/12458))
|
||||
- @0xTaoZ: ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails, shipped early in v5.39.1 ([#12217](https://github.com/prowler-cloud/prowler/pull/12217))
|
||||
- @ye11oc4t: `ses_identity_not_publicly_accessible` now evaluates every identity authorization policy, shipped early in v5.39.1 ([#12464](https://github.com/prowler-cloud/prowler/pull/12464))
|
||||
- @Zuhef: IaC provider raises typed exceptions instead of `sys.exit` when cloning the scanned repository or running Trivy fails ([#12227](https://github.com/prowler-cloud/prowler/pull/12227)), Kubernetes kubelet checks no longer disappear from the scan when a `kubelet-config` ConfigMap is broken ([#12225](https://github.com/prowler-cloud/prowler/pull/12225)), and the CLI `--slack` summary is sent for scans with no findings instead of failing with `ZeroDivisionError` ([#12229](https://github.com/prowler-cloud/prowler/pull/12229))
|
||||
- @m-khan-97: NCSC Cyber Essentials 3.3 compliance framework with Azure provider coverage across the five Cyber Essentials themes ([#11588](https://github.com/prowler-cloud/prowler/pull/11588))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.40.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.39.0" description="August 13, 2026">
|
||||
### 🤖 Lighthouse AI — Finding Skills
|
||||
|
||||
@@ -276,7 +409,7 @@ rss: true
|
||||
|
||||
All checks are fully passive, using AWS APIs and CloudTrail with no instance access or SSM agent required, and are mapped across 23 compliance frameworks, including NIST 800-53 Rev 5, PCI-DSS v4.0, ISO 27001:2022, SOC 2, HIPAA, and MITRE ATT&CK.
|
||||
|
||||
Read more about it this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave).
|
||||
Read more about it in this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave).
|
||||
|
||||
Try them out now at [cloud.prowler.com](https://cloud.prowler.com/sign-up)!
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ The AWS provider implementation follows the general [Provider structure](/develo
|
||||
The generic service pattern is described in [service page](/developer-guide/services#service-structure-and-initialisation). You can find all the right now implemented services in the following locations:
|
||||
|
||||
- Directly in the code, in location [`prowler/providers/aws/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services)
|
||||
- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view.
|
||||
- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
|
||||
|
||||
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all AWS services.
|
||||
|
||||
@@ -131,7 +131,7 @@ def _get_email_identities(self, identity):
|
||||
The AWS checks pattern is described in [checks page](/developer-guide/checks). You can find all the right now implemented checks:
|
||||
|
||||
- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/aws/services/s3/s3_bucket_acl_prohibited/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services/s3/s3_bucket_acl_prohibited))
|
||||
- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view.
|
||||
- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
|
||||
|
||||
The best reference to understand how to implement a new check is following the [check creation documentation](/developer-guide/checks#creating-a-check) and taking other similar checks as reference.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Visual Studio Code (also referred to as VSCode) provides an integrated debugger
|
||||
|
||||
### Debugging Configuration Example
|
||||
|
||||
The following file is an example of a [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file for [Virtual Studio Code](https://code.visualstudio.com/).
|
||||
The following file is an example of a [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file for [Visual Studio Code](https://code.visualstudio.com/).
|
||||
|
||||
This file must be placed inside the *.vscode* directory and named *launch.json*:
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ When adding or maintaining E2E tests for Prowler Local Server, follow these guid
|
||||
```
|
||||
|
||||
5. **Tag and document scenarios**
|
||||
- Follow the existing naming convention for suites and test cases (for example, `SCANS-E2E-001`, `PROVIDER-E2E-003`) and use tags such as `@e2e`, `@serial` and feature tags (for example, `@providers`, `@scans`,`@aws`) to filter and organize tests.
|
||||
- Follow the existing naming convention for suites and test cases (for example, `SCANS-E2E-001`, `PROVIDER-E2E-003`) and use tags such as `@e2e`, `@serial` and feature tags (for example, `@providers`, `@scans`, `@aws`) to filter and organize tests.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@@ -71,7 +71,7 @@ When adding or maintaining E2E tests for Prowler Local Server, follow these guid
|
||||
}
|
||||
);
|
||||
```
|
||||
- Document each one in the Markdown files under `ui/tests`, including **Priority**, **Tags**, **Description**, **Preconditions**, **Flow steps**, **Expected results**,**Key verification points** and **Notes**.
|
||||
- Document each one in the Markdown files under `ui/tests`, including **Priority**, **Tags**, **Description**, **Preconditions**, **Flow steps**, **Expected results**, **Key verification points** and **Notes**.
|
||||
|
||||
**Example**
|
||||
```Markdown
|
||||
@@ -256,7 +256,7 @@ To execute E2E tests for Prowler Local Server:
|
||||
pnpm run test:e2e
|
||||
```
|
||||
|
||||
This command runs Playwright with the configured projects
|
||||
This command runs Playwright with the configured projects.
|
||||
|
||||
2. **Run E2E tests with the Playwright UI runner**
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ For providers supported by Prowler, refer to [Prowler Hub](https://hub.prowler.c
|
||||
|
||||
Prowler supports several types of providers, each with its own implementation pattern and use case. Understanding these differences is key to designing your provider correctly.
|
||||
|
||||
### Classifying your Provider
|
||||
### Classifying Your Provider
|
||||
|
||||
Before implementing a new provider, you need to determine which type it belongs to. This classification will guide your implementation approach and help you choose the right patterns and libraries.
|
||||
|
||||
@@ -1090,7 +1090,7 @@ Main registration makes your provider discoverable by Prowler's core system. It'
|
||||
cis.batch_write_data_to_file()
|
||||
```
|
||||
|
||||
#### Step 11: Register in the list of providers
|
||||
#### Step 11: Register in the List of Providers
|
||||
|
||||
**Explanation:**
|
||||
This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization.
|
||||
@@ -1966,7 +1966,7 @@ Main registration makes your provider discoverable by Prowler's core system. It'
|
||||
|
||||
This step is the same as the [SDK providers](#step-10-register-in-main).
|
||||
|
||||
#### Step 11: Register in the list of providers
|
||||
#### Step 11: Register in the List of Providers
|
||||
|
||||
**Explanation:**
|
||||
This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization.
|
||||
@@ -2648,7 +2648,7 @@ Main registration makes your provider discoverable by Prowler's core system. It'
|
||||
|
||||
This step is the same as the [SDK providers](#step-10-register-in-main).
|
||||
|
||||
#### Step 7: Register in the list of providers
|
||||
#### Step 7: Register in the List of Providers
|
||||
|
||||
**Explanation:**
|
||||
This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization.
|
||||
@@ -2808,7 +2808,7 @@ def validate_your_provider_uid(value):
|
||||
**Provider Model:**
|
||||
The `Provider` model already exists and supports all provider types. Ensure your provider type is included in the choices.
|
||||
|
||||
### 2.2. Add the provider to the Provider Choices
|
||||
### 2.2. Add the Provider to the Provider Choices
|
||||
|
||||
Update the `return_prowler_provider` function to include your provider. This function is crucial for the API to instantiate the correct provider class.
|
||||
|
||||
@@ -3209,7 +3209,7 @@ class YourProviderAPITestCase(APITestCase):
|
||||
self.assertEqual(response.status_code, 201)
|
||||
```
|
||||
|
||||
#### 2.6.1. Add your mocked provider to the tests
|
||||
#### 2.6.1. Add Your Mocked Provider to the Tests
|
||||
|
||||
If needed, add a named provider fixture or extend the provider factory defaults so tests can request only the provider they need.
|
||||
|
||||
@@ -3272,7 +3272,7 @@ Your provider will be available through these endpoints:
|
||||
- `DELETE /api/v1/providers/{id}/` - Delete provider
|
||||
- `POST /api/v1/providers/secrets/` - Add provider credentials
|
||||
|
||||
### 2.9. Update the provider if needed
|
||||
### 2.9. Update the Provider If Needed
|
||||
|
||||
Depending on your provider's authentication requirements, you may need to add new authentication methods that are compatible with the API. This involves updating the provider class to support additional credential types beyond the basic ones.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ A compliance framework must represent the **complete state** of the source catal
|
||||
Requirement coverage feeds the compliance percentage calculations and the metadata surfaces (dashboards, widgets, exports). Missing requirements skew those metrics and break the report as a faithful snapshot of the framework.
|
||||
</Warning>
|
||||
|
||||
### Two supported schemas
|
||||
### Two Supported Schemas
|
||||
|
||||
| Schema | When to use | File location | Discovered as |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -45,7 +45,7 @@ Before adding a new framework, complete the following checks:
|
||||
|
||||
## Universal Compliance Framework
|
||||
|
||||
### Where the file lives
|
||||
### Where the File Lives
|
||||
|
||||
Place the file at the top level of the compliance directory:
|
||||
|
||||
@@ -57,7 +57,7 @@ Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/comp
|
||||
|
||||
The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora_2022_2554.json` → `dora_2022_2554`).
|
||||
|
||||
### Top-level structure
|
||||
### Top-Level Structure
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -198,7 +198,7 @@ Per requirement:
|
||||
|
||||
For MITRE-style frameworks, additional optional fields are available on the requirement: `tactics`, `sub_techniques`, `platforms`, `technique_url` (these are populated automatically when adapting a legacy MITRE JSON to the universal model).
|
||||
|
||||
### Multi-provider frameworks
|
||||
### Multi-Provider Frameworks
|
||||
|
||||
A single universal file can cover any number of providers. The framework appears under each provider's `--list-compliance` output as long as **at least one** requirement has that provider key in its `checks` dict.
|
||||
|
||||
@@ -226,7 +226,7 @@ The legacy schema spans **four layers** — a complete contribution must touch e
|
||||
|
||||
The universal schema collapses Layers 3 and 4 into declarative configuration inside the JSON — that is the main reason it is preferred for new contributions.
|
||||
|
||||
### Directory structure and file naming
|
||||
### Directory Structure and File Naming
|
||||
|
||||
Compliance frameworks live at:
|
||||
|
||||
@@ -259,7 +259,7 @@ prowler/lib/outputs/compliance/<framework>/
|
||||
└── __init__.py
|
||||
```
|
||||
|
||||
### JSON schema reference
|
||||
### JSON Schema Reference
|
||||
|
||||
Every legacy compliance file is a JSON document with the following top-level keys. `Framework`, `Name` and `Provider` are validated non-empty by the root validator `framework_and_provider_must_not_be_empty` (`compliance_models.py`).
|
||||
|
||||
@@ -362,7 +362,7 @@ For the remaining attribute classes (`AWS_Well_Architected_Requirement_Attribute
|
||||
The `Attributes` field is a Pydantic `Union`. The generic attribute model **must** remain the last element of that Union — otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped. Adding a brand-new attribute shape requires inserting the Pydantic class **before** `Generic_Compliance_Requirement_Attribute`.
|
||||
</Note>
|
||||
|
||||
#### Minimal working example
|
||||
#### Minimal Working Example
|
||||
|
||||
The following snippet is a complete, valid framework file named `my_framework_1.0_aws.json`, saved at `prowler/compliance/aws/my_framework_1.0_aws.json`. It uses the generic attribute shape for simplicity.
|
||||
|
||||
@@ -408,7 +408,7 @@ The following snippet is a complete, valid framework file named `my_framework_1.
|
||||
}
|
||||
```
|
||||
|
||||
### Mapping checks to requirements
|
||||
### Mapping Checks to Requirements
|
||||
|
||||
Each requirement links to the Prowler checks that, together, produce a PASS or FAIL verdict for that control.
|
||||
|
||||
@@ -425,7 +425,7 @@ To discover available checks:
|
||||
uv run python prowler-cli.py <provider> --list-checks
|
||||
```
|
||||
|
||||
### Supporting multiple providers (legacy)
|
||||
### Supporting Multiple Providers (Legacy)
|
||||
|
||||
The legacy schema binds each file to a single provider. To cover several providers with the same framework, ship one JSON file per provider:
|
||||
|
||||
@@ -439,7 +439,7 @@ Keep the `Framework` and `Version` values identical across the files so the disp
|
||||
|
||||
For a brand-new framework that spans several providers, **prefer the universal schema** — it covers every provider from a single file. If you must use the legacy schema, add one transformer per provider in `prowler/lib/outputs/compliance/<framework>/` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter).
|
||||
|
||||
### Output formatter
|
||||
### Output Formatter
|
||||
|
||||
Legacy frameworks render in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework. Universal frameworks do **not** need a Python output formatter — the `outputs` config inside the JSON drives rendering — so this section applies only to the legacy schema.
|
||||
|
||||
@@ -453,19 +453,19 @@ prowler/lib/outputs/compliance/my_framework/
|
||||
└── models.py # CSV row Pydantic model
|
||||
```
|
||||
|
||||
#### Step 1 — Define the CSV row model
|
||||
#### Step 1 — Define the CSV Row Model
|
||||
|
||||
In `models.py`, declare a Pydantic v1 model with one field per CSV column. Use existing models such as `AWSCISModel` in `prowler/lib/outputs/compliance/cis/models.py` as the reference. Fields typically include `Provider`, `Description`, `AccountId`, `Region`, `AssessmentDate`, `Requirements_Id`, `Requirements_Description`, one `Requirements_Attributes_*` field per attribute key, plus the finding fields `Status`, `StatusExtended`, `ResourceId`, `ResourceName`, `CheckId`, `Muted`, `Framework`, `Name`.
|
||||
|
||||
#### Step 2 — Implement the transformer
|
||||
#### Step 2 — Implement the Transformer
|
||||
|
||||
In `my_framework_aws.py`, subclass `ComplianceOutput` from `prowler.lib.outputs.compliance.compliance_output` and implement `transform(findings, compliance, compliance_name)`. Iterate over `findings`, match each finding to the requirements it satisfies through `finding.compliance.get(compliance_name, [])`, and append one row per attribute to `self._data`.
|
||||
|
||||
#### Step 3 — Add the summary-table dispatcher
|
||||
#### Step 3 — Add the Summary-Table Dispatcher
|
||||
|
||||
In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_metadata, compliance_framework, output_filename, output_directory, compliance_overview)` following the pattern in `prowler/lib/outputs/compliance/cis/cis.py`.
|
||||
|
||||
#### Step 4 — Register the framework in the dispatchers
|
||||
#### Step 4 — Register the Framework in the Dispatchers
|
||||
|
||||
- Add the dispatcher call in `prowler/lib/outputs/compliance/compliance.py`, inside `display_compliance_table`, with a branch such as `elif "my_framework" in compliance_framework:`.
|
||||
- Register the CSV model and transformer in `prowler/lib/outputs/compliance/compliance_output.py` so the CSV file is emitted during the scan.
|
||||
@@ -474,7 +474,7 @@ In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_me
|
||||
For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no custom formatter is needed. The generic formatter in `prowler/lib/outputs/compliance/generic/` handles them automatically, provided the JSON validates against the generic attribute schema.
|
||||
</Note>
|
||||
|
||||
### Legacy-to-universal adapter
|
||||
### Legacy-to-Universal Adapter
|
||||
|
||||
At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py`), which: (a) flattens the first element of `Attributes` into a flat `attributes` dict, (b) wraps `Checks` as `{provider_lower: [...]}`, (c) infers `attributes_metadata` from the matched Pydantic class via `_infer_attribute_metadata()`. The rest of Prowler (CSV/OCSF/PDF output, CLI table) then treats both formats identically.
|
||||
|
||||
@@ -497,7 +497,7 @@ Configuration guardrails close that gap. A requirement declares the configuratio
|
||||
Guardrails are an **optional** safety net for configurable checks. A requirement that maps only to non-configurable checks does not need them. When the field is absent, behavior is unchanged.
|
||||
</Note>
|
||||
|
||||
### Where guardrails are declared
|
||||
### Where Guardrails Are Declared
|
||||
|
||||
The field is attached to each requirement and exists in both schemas:
|
||||
|
||||
@@ -506,7 +506,7 @@ The field is attached to each requirement and exists in both schemas:
|
||||
|
||||
When a legacy file is adapted to the universal model, `adapt_legacy_to_universal()` copies `ConfigRequirements` into `config_requirements` (`compliance_models.py`), so downstream code only ever reads one shape.
|
||||
|
||||
### Constraint schema
|
||||
### Constraint Schema
|
||||
|
||||
Each entry in the list is a single constraint with the following fields:
|
||||
|
||||
@@ -533,7 +533,7 @@ Each entry in the list is a single constraint with the following fields:
|
||||
`subset` / `superset` require both the applied value and `Value` to be lists; any other type is treated as not satisfied. For `eq` against a boolean, declare `Value` as a JSON boolean (`false`, not `0`) — the model keeps booleans distinct from integers.
|
||||
</Note>
|
||||
|
||||
### How guardrails are evaluated
|
||||
### How Guardrails Are Evaluated
|
||||
|
||||
All evaluation lives in one shared module, `prowler/lib/check/compliance_config_eval.py`, consumed by every compliance output (CSV, OCSF, and the CLI tables) and reused by the Prowler API backend so the rule is defined exactly once.
|
||||
|
||||
@@ -547,7 +547,7 @@ All evaluation lives in one shared module, `prowler/lib/check/compliance_config_
|
||||
Guardrails only ever make a result **stricter** (they can turn PASS into FAIL); they never relax a real FAIL into PASS. A requirement with no constraints, or whose keys all use defaults, is reported exactly as before.
|
||||
</Warning>
|
||||
|
||||
### Example: legacy framework
|
||||
### Example: Legacy Framework
|
||||
|
||||
From `prowler/compliance/aws/cis_6.0_aws.json`, requirement 2.11 declares two guardrails — one per configurable check it maps to:
|
||||
|
||||
@@ -590,7 +590,7 @@ A boolean guardrail from the same file: requirement 2.5 (IAM Access Analyzer) on
|
||||
]
|
||||
```
|
||||
|
||||
### Example: universal framework
|
||||
### Example: Universal Framework
|
||||
|
||||
The universal schema uses the lowercase `config_requirements` key with the identical object shape:
|
||||
|
||||
@@ -616,7 +616,7 @@ The universal schema uses the lowercase `config_requirements` key with the ident
|
||||
|
||||
Each constraint declares the `Provider` it targets so the guardrail is only evaluated on scans of that provider — essential for universal frameworks like CSA CCM and DORA, where one requirement maps checks across `aws`, `azure`, `gcp` and more. Because the operator is `subset`, adding `"TLS 1.0"` to `recommended_minimal_tls_versions` widens the allowlist beyond `["TLS 1.2", "TLS 1.3"]` and the requirement is forced to FAIL.
|
||||
|
||||
### What the user sees
|
||||
### What the User Sees
|
||||
|
||||
With a loosened config, the affected requirement's findings report:
|
||||
|
||||
@@ -630,7 +630,7 @@ StatusExtended: Configuration not valid for this requirement. The check
|
||||
|
||||
The same `Configuration not valid for this requirement.` message appears identically across the CSV, OCSF, and console-table outputs.
|
||||
|
||||
### Authoring guidelines
|
||||
### Authoring Guidelines
|
||||
|
||||
- Declare a guardrail only for keys whose value actually changes whether the requirement is met. Most configurable checks do not need one.
|
||||
- Set `Value` to the **strictest** configuration the control tolerates — the same number the control text cites (CIS 45 days, NIST ≤90, and so on).
|
||||
@@ -639,7 +639,7 @@ The same `Configuration not valid for this requirement.` message appears identic
|
||||
- Pick the operator from the value's role: a max threshold is `lte`, a min threshold is `gte`, a toggle is `eq`, an allowlist is `subset`, a denylist is `superset`.
|
||||
- An unrecognized operator does **not** block the requirement — a malformed constraint is treated as satisfied rather than failing the whole framework. Validate your JSON with the tests below.
|
||||
|
||||
### Testing guardrails
|
||||
### Testing Guardrails
|
||||
|
||||
The shared evaluator and the per-output integration are covered by:
|
||||
|
||||
@@ -670,7 +670,7 @@ Prowler matches frameworks by concatenating `Framework` and `Version`. A missing
|
||||
|
||||
Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists.
|
||||
|
||||
### 1. Schema validation
|
||||
### 1. Schema Validation
|
||||
|
||||
For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora_2022_2554.json` that key is `dora_2022_2554`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`.
|
||||
|
||||
@@ -688,7 +688,7 @@ bulk = get_bulk_compliance_frameworks_universal("aws")
|
||||
assert "<your_framework_filename_without_json>" in bulk
|
||||
```
|
||||
|
||||
### 2. Check existence cross-check
|
||||
### 2. Check Existence Cross-Check
|
||||
|
||||
There is **no automatic check-existence validation** at load time. Cross-check that every check name in your framework maps to a real check directory:
|
||||
|
||||
@@ -708,7 +708,7 @@ missing = referenced - real
|
||||
assert not missing, f"checks referenced in framework but not found in repo: {sorted(missing)}"
|
||||
```
|
||||
|
||||
### 3. CLI smoke test
|
||||
### 3. CLI Smoke Test
|
||||
|
||||
```bash
|
||||
uv run python prowler-cli.py <provider> --list-compliance
|
||||
@@ -728,7 +728,7 @@ Verify that:
|
||||
- The CLI summary table lists every section / pillar of the framework.
|
||||
- Findings roll up under the expected requirements.
|
||||
|
||||
### 4. Inspect the CSV output
|
||||
### 4. Inspect the CSV Output
|
||||
|
||||
Open the generated CSV and confirm:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ This guide explains how to add a **Server-Sent Events (SSE)** endpoint to the Pr
|
||||
The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature endpoint streams over SSE out of the box — this guide shows how to build one on top of the shared base.
|
||||
</Info>
|
||||
|
||||
## When to use SSE
|
||||
## When to Use SSE
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
@@ -22,7 +22,7 @@ The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature
|
||||
|
||||
SSE is the right tool when the **client only consumes**: scan progress, long-running job checkpoints, streamed LLM tokens, cross-client resource-sync notifications. It rides on plain HTTP, reconnects automatically in the browser via the native [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) API, and needs no extra protocol.
|
||||
|
||||
## How it works
|
||||
## How It Works
|
||||
|
||||
SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eventstream) and a small platform layer in `api/src/backend/api/sse/`:
|
||||
|
||||
@@ -34,11 +34,11 @@ SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eve
|
||||
| `make_channel_name` / `tenant_id_from_channel` | `api/sse/utils.py` | Single source of truth for the channel-name format, so publishers and the channel manager agree byte-for-byte. |
|
||||
| Settings | `config/settings/eventstream.py` | Valkey Pub/Sub backend (dedicated DB), channel manager, allowed headers. |
|
||||
|
||||
### Transport: the server runs on ASGI
|
||||
### Transport: The Server Runs on ASGI
|
||||
|
||||
SSE connections are long-lived. Holding one open per synchronous worker would exhaust the worker pool, so the API runs under Gunicorn's native **`asgi` worker** (`config.asgi:application`). Streams are parked on the event loop while ordinary CRUD endpoints keep their synchronous execution (Django runs sync views in a thread-sensitive executor under ASGI). This is configured in `config/guniconf.py` and used by both the dev and production entrypoints — no separate server process is needed.
|
||||
|
||||
### The data flow
|
||||
### The Data Flow
|
||||
|
||||
```
|
||||
publisher (Celery task / view) subscriber (browser, CLI)
|
||||
@@ -53,7 +53,7 @@ publisher (Celery task / view) subscriber (browser, CLI)
|
||||
|
||||
A publisher anywhere in the system (most often a Celery task) calls `send_event(channel, event_type, payload)`. `django-eventstream` fans it out over Valkey Pub/Sub to every connection subscribed to that channel.
|
||||
|
||||
## Adding an SSE endpoint to your feature
|
||||
## Adding an SSE Endpoint to Your Feature
|
||||
|
||||
The example below streams progress for a long-running **scan**. Adapt the resource, prefix, and event names to your feature.
|
||||
|
||||
@@ -161,7 +161,7 @@ publish_end(channel, scan_id=str(scan.id))
|
||||
|
||||
</Steps>
|
||||
|
||||
## Event naming convention
|
||||
## Event Naming Convention
|
||||
|
||||
Every event uses an event type of the form **`<resource>.<verb>`** (lowercased, dot-separated). The verb comes from this platform-wide vocabulary — if you need a verb that is not listed, document the addition in this guide so the catalog stays discoverable.
|
||||
|
||||
@@ -197,7 +197,7 @@ curl -N -H "Authorization: Bearer $JWT" \
|
||||
https://<host>/api/v1/scans/$SCAN_ID/event-stream
|
||||
```
|
||||
|
||||
## Tenant isolation & security model
|
||||
## Tenant Isolation & Security Model
|
||||
|
||||
Authorization is enforced at two layers:
|
||||
|
||||
@@ -206,7 +206,7 @@ Authorization is enforced at two layers:
|
||||
|
||||
Because the tenant id lives inside the channel name, this gate works for any feature without the platform knowing anything about it.
|
||||
|
||||
## Reconnect & state recovery
|
||||
## Reconnect & State Recovery
|
||||
|
||||
The platform deliberately ships **without server-side replay** (`is_channel_reliable` returns `False`). When a client reconnects, it does **not** receive missed events. Instead:
|
||||
|
||||
@@ -215,7 +215,7 @@ The platform deliberately ships **without server-side replay** (`is_channel_reli
|
||||
|
||||
Design your event payloads accordingly: deltas are ephemeral and concatenated in-flight; the durable truth always lives behind a REST resource.
|
||||
|
||||
## Local development
|
||||
## Local Development
|
||||
|
||||
- The dev and production entrypoints both launch Gunicorn with the `asgi` worker (`config.asgi:application`). In dev, `DJANGO_DEBUG=True` enables hot reload; `preload_app` is automatically disabled under debug so edited code is picked up.
|
||||
- SSE uses a **dedicated Valkey database** (`EVENTSTREAM_VALKEY_DB`, default `2`) kept separate from the Celery broker so a noisy broker cannot crowd out streaming traffic. It reuses the same `VALKEY_*` connection settings as the rest of the platform.
|
||||
|
||||
@@ -537,7 +537,7 @@ This architecture allows Prowler to efficiently scan AWS accounts with resources
|
||||
|
||||
## Best Practices
|
||||
|
||||
- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized by to maximize performance and reduce scan time.
|
||||
- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized to maximize performance and reduce scan time.
|
||||
- Define a Pydantic `BaseModel` for every resource you manage, and use these models for all resource data handling.
|
||||
- Log every major step (start, success, error) in resource discovery and attribute collection for traceability and debugging; include as much context as possible.
|
||||
- Catch and log all exceptions, providing detailed context (region, subscription, resource, error type, line number) to aid troubleshooting.
|
||||
|
||||
@@ -154,7 +154,7 @@ Failing to update this table when adding cross-service dependencies may result i
|
||||
For AWS provider, different testing approaches apply based on API coverage based on several criteria.
|
||||
|
||||
<Note>
|
||||
Prowler leverages and contributes to the[Moto](https://github.com/getmoto/moto) library for mocking AWS infrastructure in tests.
|
||||
Prowler leverages and contributes to the [Moto](https://github.com/getmoto/moto) library for mocking AWS infrastructure in tests.
|
||||
|
||||
</Note>
|
||||
- AWS API Calls Covered by [Moto](https://github.com/getmoto/moto):
|
||||
@@ -408,7 +408,7 @@ In all above scenarios, check execution must occur within the context of mocked
|
||||
|
||||
When a service requires API calls that are partially covered by the Moto decorator, additional mocking is necessary. In such cases, custom mocked API calls must be implemented alongside Moto to ensure full coverage.
|
||||
|
||||
To achieve this, mock the `botocore.client.BaseClient._make_api_call` function—the method responsible for making actual API requests to AWS—using `mock.patch <https://docs.python.org/3/library/unittest.mock.html#patch>`:
|
||||
To achieve this, mock the `botocore.client.BaseClient._make_api_call` function—the method responsible for making actual API requests to AWS—using [`mock.patch`](https://docs.python.org/3/library/unittest.mock.html#patch):
|
||||
|
||||
```python
|
||||
|
||||
@@ -475,7 +475,7 @@ However, if additional `moto` decorators are applied alongside the patch, Moto w
|
||||
|
||||
</Note>
|
||||
<Note>
|
||||
The source of the above implementation can be found here:[Patch Other Services with Moto](https://docs.getmoto.org/en/latest/docs/services/patching\_other\_services.html)
|
||||
The source of the above implementation can be found here: [Patch Other Services with Moto](https://docs.getmoto.org/en/latest/docs/services/patching_other_services.html)
|
||||
|
||||
</Note>
|
||||
#### Mocking Several Services
|
||||
@@ -603,7 +603,7 @@ with mock.patch(
|
||||
|
||||
will cause that the service is initialized only once—at the moment of mocking out `set_mocked_aws_provider([<region>])` using `mock.patch`.
|
||||
|
||||
Later, when Python attempts to import the client at the check level, the execution continues using`from prowler.providers.<provider>.services.<service>.<service>_client`. As a result of it being already mocked out, the execution will continue using `service_client` without getting into `<service>_client.py`.
|
||||
Later, when Python attempts to import the client at the check level, the execution continues using `from prowler.providers.<provider>.services.<service>.<service>_client`. As a result of it being already mocked out, the execution will continue using `service_client` without getting into `<service>_client.py`.
|
||||
|
||||
### Testing AWS Services
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ title: 'Basic Usage'
|
||||
|
||||
## Running Prowler
|
||||
|
||||
Running Prowler requires specifying the provider (e.g `aws`, `gcp`, `azure`, `kubernetes`, `m365`, `github`, `iac` or `mongodbatlas`):
|
||||
Running Prowler requires specifying the provider (e.g. `aws`, `gcp`, `azure`, `kubernetes`, `m365`, `github`, `iac` or `mongodbatlas`):
|
||||
|
||||
<Note>
|
||||
If no provider is specified, AWS is used by default for backward compatibility with Prowler v2.
|
||||
|
||||
@@ -128,8 +128,8 @@ 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.40.0"
|
||||
PROWLER_API_VERSION="5.40.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -161,7 +161,7 @@ The Prowler MCP Server enables powerful workflows through AI assistants:
|
||||
- "What authentication methods does Prowler support for Azure?"
|
||||
- "How can I contribute with a new security check to Prowler?"
|
||||
|
||||
### Example: Creating a custom dashboard with Prowler extracted data
|
||||
### Example: Creating a Custom Dashboard with Prowler Extracted Data
|
||||
|
||||
In the next example you can see how to create a dashboard using Prowler MCP Server and Claude Desktop.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -22,7 +22,7 @@ See section [Logging](/user-guide/cli/tutorials/logging) for further information
|
||||
|
||||
Common issues with the Docker Compose installation of Prowler Local Server.
|
||||
|
||||
### Problem adding AWS Provider using "Connect assuming IAM Role" in Docker
|
||||
### Problem Adding AWS Provider Using "Connect assuming IAM Role" in Docker
|
||||
|
||||
See [GitHub Issue #7745](https://github.com/prowler-cloud/prowler/issues/7745) for more details.
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ def fixer(resource_id: str) -> bool:
|
||||
return True
|
||||
```
|
||||
|
||||
## Fixer Config file
|
||||
## Fixer Config File
|
||||
|
||||
For some fixers, you can have configurable parameters depending on your use case. You can either use the default config file in `prowler/config/fixer_config.yaml` or create a custom config file and pass it to the fixer with the `--fixer-config` flag. The config file should be a YAML file with the following structure:
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ title: 'Miscellaneous'
|
||||
|
||||
## Prowler Version
|
||||
|
||||
### Showing the Prowler version:
|
||||
### Showing the Prowler Version
|
||||
|
||||
```console
|
||||
prowler <provider> -V/-v/--version
|
||||
@@ -22,7 +22,7 @@ To enable verbose mode in Prowler, similar to Version 2, use:
|
||||
prowler <provider> --verbose
|
||||
```
|
||||
|
||||
### Filter findings by status
|
||||
### Filter Findings by Status
|
||||
|
||||
Prowler allows filtering findings based on their status, ensuring reports and CLI display only relevant findings:
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ Accounts:
|
||||
|
||||
## AWS Mutelist
|
||||
|
||||
### Muting specific AWS regions
|
||||
### Muting Specific AWS Regions
|
||||
|
||||
If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`:
|
||||
|
||||
|
||||
@@ -43,8 +43,7 @@ prowler <provider> --categories secrets
|
||||
|
||||
Several checks analyse resources that are exposed to the Internet, these are:
|
||||
|
||||
1. apigateway\_restapi\_public
|
||||
|
||||
- apigateway\_restapi\_public
|
||||
- appstream\_fleet\_default\_internet\_access\_disabled
|
||||
- awslambda\_function\_not\_publicly\_accessible
|
||||
- ec2\_ami\_public
|
||||
@@ -58,8 +57,6 @@ Several checks analyse resources that are exposed to the Internet, these are:
|
||||
- ecr\_repositories\_not\_publicly\_accessible
|
||||
- eks\_control\_plane\_endpoint\_access\_restricted
|
||||
- eks\_endpoints\_not\_publicly\_accessible
|
||||
- eks\_control\_plane\_endpoint\_access\_restricted
|
||||
- eks\_endpoints\_not\_publicly\_accessible
|
||||
- elbv2\_internet\_facing
|
||||
- kms\_key\_not\_publicly\_accessible
|
||||
- opensearch\_service\_domains\_not\_publicly\_accessible
|
||||
|
||||
@@ -144,25 +144,25 @@ prowler alibabacloud --ecs-ram-role RoleName
|
||||
|
||||
### Step 2: Run the First Scan
|
||||
|
||||
#### Scan all regions
|
||||
#### Scan All Regions
|
||||
|
||||
```bash
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
#### Scan specific regions
|
||||
#### Scan Specific Regions
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --region cn-hangzhou cn-shanghai
|
||||
```
|
||||
|
||||
#### Run specific checks
|
||||
#### Run Specific Checks
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --checks ram_no_root_access_key ram_user_mfa_enabled_console_access
|
||||
```
|
||||
|
||||
#### Run a compliance framework
|
||||
#### Run a Compliance Framework
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --compliance cis_2.0_alibabacloud
|
||||
|
||||
@@ -167,7 +167,7 @@ Include the `ExternalId` parameter in the StackSet if required by the organizati
|
||||
|
||||
When encountering issues during deployment or needing to target specific OUs or environments (e.g., dev/staging/prod), reach out to the Prowler team via [Slack Community](https://prowler.com/slack) or [Support](mailto:support@prowler.com).
|
||||
|
||||
## Extra: Run Prowler across all accounts in AWS Organizations by assuming roles
|
||||
## Extra: Run Prowler Across All Accounts in AWS Organizations by Assuming Roles
|
||||
|
||||
### Running Prowler Across All AWS Organization Accounts
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the
|
||||
|
||||
<VersionBadge version="5.15.0" />
|
||||
|
||||
### Step 1: Add the provider
|
||||
### Step 1: Add the Provider
|
||||
|
||||
1. Navigate to **Providers** and click **Add Provider**.
|
||||

|
||||
@@ -45,13 +45,13 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the
|
||||

|
||||
4. (Optional) Add a friendly alias to identify this organization in dashboards.
|
||||
|
||||
### Step 2: Provide API credentials
|
||||
### Step 2: Provide API Credentials
|
||||
|
||||
1. Click **Next** to open the credentials form.
|
||||
2. Paste the **Atlas Public Key** and **Atlas Private Key** generated in the Atlas console.
|
||||

|
||||
|
||||
### Step 3: Test the connection and start scanning
|
||||
### Step 3: Test the Connection and Start Scanning
|
||||
|
||||
1. Click **Test connection** to ensure Prowler Cloud can reach the Atlas API.
|
||||
2. Save the credentials. The provider will appear in the list with its current connection status.
|
||||
@@ -66,11 +66,11 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the
|
||||
|
||||
You can also run MongoDB Atlas assessments directly from the CLI. Both command-line flags and environment variables are supported.
|
||||
|
||||
### Step 1: Select an authentication method
|
||||
### Step 1: Select an Authentication Method
|
||||
|
||||
Choose one of the following authentication methods:
|
||||
|
||||
#### Command-line arguments
|
||||
#### Command-Line Arguments
|
||||
|
||||
```bash
|
||||
prowler mongodbatlas \
|
||||
@@ -78,7 +78,7 @@ prowler mongodbatlas \
|
||||
--atlas-private-key <private_key>
|
||||
```
|
||||
|
||||
#### Environment variables
|
||||
#### Environment Variables
|
||||
|
||||
```bash
|
||||
export ATLAS_PUBLIC_KEY=<public_key>
|
||||
@@ -86,9 +86,9 @@ export ATLAS_PRIVATE_KEY=<private_key>
|
||||
prowler mongodbatlas
|
||||
```
|
||||
|
||||
### Step 2: Run the first scan
|
||||
### Step 2: Run the First Scan
|
||||
|
||||
#### Scan all projects and clusters
|
||||
#### Scan All Projects and Clusters
|
||||
|
||||
```bash
|
||||
prowler mongodbatlas
|
||||
@@ -96,7 +96,7 @@ prowler mongodbatlas
|
||||
|
||||
This command enumerates all projects accessible to the API key and scans every cluster.
|
||||
|
||||
#### Scan a specific project
|
||||
#### Scan a Specific Project
|
||||
|
||||
Add the `--atlas-project-id` flag when you only want to assess one project:
|
||||
|
||||
@@ -104,7 +104,7 @@ Add the `--atlas-project-id` flag when you only want to assess one project:
|
||||
prowler mongodbatlas --atlas-project-id <project-id>
|
||||
```
|
||||
|
||||
### Additional tips
|
||||
### Additional Tips
|
||||
|
||||
- Combine flags (for example, `--checks` or `--services`) just like with other providers.
|
||||
- Use `--output-modes` to export findings in JSON, CSV, ASFF, etc.
|
||||
|
||||
@@ -67,7 +67,7 @@ The service application must be assigned **one** of the following Okta admin rol
|
||||
|
||||
Okta's Management API enforces a two-layer authorization model: an OAuth **scope** decides which API endpoints the token can call, and an **admin role** decides whether the call returns data. With only a scope granted, the token mint succeeds but every read returns `403 Forbidden`. Read-Only Administrator is the minimum role that lets the granted `okta.*.read` scopes return configuration data to Prowler's checks; without it, the credential probe at provider startup fails and the scan never gets to evaluate any check.
|
||||
|
||||
#### When Super Administrator is required
|
||||
#### When Super Administrator Is Required
|
||||
|
||||
Four checks need to resolve the Authentication Policy bound to Okta's first-party apps (Okta Admin Console, Okta Dashboard) and depend on `/api/v1/apps` returning those system apps — which Okta restricts to Super Administrator:
|
||||
|
||||
@@ -92,17 +92,17 @@ Read-Only Administrator stays the recommended default for the least-privilege fr
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### 1. Go to the admin console
|
||||
### 1. Go to the Admin Console
|
||||
|
||||

|
||||
|
||||
### 2. [Optional] - Disable the privilege-escalation bypass (org-wide, one-time)
|
||||
### 2. [Optional] - Disable the Privilege-Escalation Bypass (Org-Wide, One-Time)
|
||||
|
||||
In the Okta Admin Console, go to **Settings → Account → Public client app admins** and ensure it is **off**. When enabled, every API Services app can be auto-assigned the Super Administrator role after scopes are granted, which would invalidate the read-only premise of this integration.
|
||||
|
||||

|
||||
|
||||
### 3. Create the API Services app
|
||||
### 3. Create the API Services App
|
||||
|
||||
1. Go to **Applications → Applications**.
|
||||
|
||||
@@ -118,7 +118,7 @@ In the Okta Admin Console, go to **Settings → Account → Public client app ad
|
||||
|
||||

|
||||
|
||||
### 4. Switch to private-key authentication and generate a keypair
|
||||
### 4. Switch to Private-Key Authentication and Generate a Keypair
|
||||
|
||||
On the new app's **General** tab, scroll to **Client Credentials**:
|
||||
|
||||
@@ -136,13 +136,13 @@ Okta displays the private key **only once**. If you close the modal without copy
|
||||
|
||||

|
||||
|
||||
### 5. Grant the required OAuth scopes
|
||||
### 5. Grant the Required OAuth Scopes
|
||||
|
||||
On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`.
|
||||
|
||||

|
||||
|
||||
### 6. Assign an admin role
|
||||
### 6. Assign an Admin Role
|
||||
|
||||
On the app, open the **Admin roles** tab and click **Edit assignments → Add assignment**:
|
||||
|
||||
@@ -155,7 +155,7 @@ To additionally evaluate the first-party application checks (Okta Admin Console
|
||||
|
||||

|
||||
|
||||
### 7. [Optional] Verify DPoP setting
|
||||
### 7. [Optional] Verify DPoP Setting
|
||||
|
||||
Prowler sends DPoP (Demonstrating Proof of Possession) proofs on every token request. The integration works whether the **Require Demonstrating Proof of Possession (DPoP) header in token requests** setting on the service app is on or off — but enabling it is the more secure default.
|
||||
|
||||
@@ -206,20 +206,20 @@ The org domain must be `<org>.okta.com` (or `.oktapreview.com` / `.okta-emea.com
|
||||
|
||||
The file at `OKTA_PRIVATE_KEY_FILE` is missing, unreadable, or empty. Confirm the path and that the file contains a non-empty PEM block or JWK JSON document.
|
||||
|
||||
### `OktaInvalidCredentialsError` at provider init
|
||||
### `OktaInvalidCredentialsError` at Provider Init
|
||||
|
||||
Prowler validates credentials at startup by listing one sign-on policy. This error indicates the credential material itself was rejected:
|
||||
|
||||
- **`invalid_client`** — the public key registered in Okta does not match the private key on disk. Generate a fresh keypair and try again.
|
||||
|
||||
### `OktaInsufficientPermissionsError` at provider init
|
||||
### `OktaInsufficientPermissionsError` at Provider Init
|
||||
|
||||
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
|
||||
|
||||
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
|
||||
- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
|
||||
|
||||
### Application-service checks return MANUAL on first-party apps
|
||||
### Application-Service Checks Return MANUAL on First-Party Apps
|
||||
|
||||
When the service app runs with Read-Only Administrator, the five application-service checks targeting the Okta Admin Console and Okta Dashboard return MANUAL. This is by design — Okta restricts the underlying endpoints (`/api/v1/first-party-app-settings/{appName}` and `/api/v1/apps` for first-party app `name` values `saasure` / `okta_enduser`) to **Super Administrator**. Assign the Super Administrator role to the service app to evaluate those checks. See [Required Admin Role](#required-admin-role) for the full list.
|
||||
|
||||
|
||||
@@ -141,18 +141,18 @@ Muting a finding does not fix the underlying configuration. Review the finding b
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Triage controls do not appear
|
||||
### Triage Controls Do Not Appear
|
||||
|
||||
Make sure the row is an individual finding row. Finding Groups rows do not show triage controls. Expand a group to see affected resources and their triage controls.
|
||||
|
||||
### Changes cannot be saved
|
||||
### Changes Cannot Be Saved
|
||||
|
||||
Confirm that the user role has **Manage Scans** permission. Prowler Local Server does not support Findings Triage writes.
|
||||
|
||||
### Resolved or Reopened is missing from the selector
|
||||
### Resolved or Reopened Is Missing from the Selector
|
||||
|
||||
**Reopened** is always automatic. **Resolved** is set automatically from scan result changes and appears as a selector option only on `MANUAL` findings, where it records a [Manual Pass](#verify-a-manual-finding-as-pass). On findings with any other status, this is expected.
|
||||
|
||||
### Risk Accepted or False Positive muted a finding
|
||||
### Risk Accepted or False Positive Muted a Finding
|
||||
|
||||
This is expected. Those statuses create a mute rule through Mutelist.
|
||||
|
||||
@@ -28,7 +28,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M
|
||||
|
||||
## Usage
|
||||
|
||||
### AWS scan
|
||||
### AWS Scan
|
||||
|
||||
```yaml
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
@@ -41,7 +41,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M
|
||||
AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }}
|
||||
```
|
||||
|
||||
### Push findings to Prowler Cloud
|
||||
### Push Findings to Prowler Cloud
|
||||
|
||||
Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-import-findings) for centralized visibility, compliance tracking, and team collaboration.
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- GitHub Code Scanning is free for public repositories. Private repositories require a [GitHub Code Security](https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security) license.
|
||||
</Warning>
|
||||
|
||||
### Combine push-to-cloud with SARIF upload
|
||||
### Combine Push-to-Cloud with SARIF Upload
|
||||
|
||||
```yaml
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }}
|
||||
```
|
||||
|
||||
### Scan the current repository with the GitHub provider
|
||||
### Scan the Current Repository with the GitHub Provider
|
||||
|
||||
```yaml
|
||||
name: Prowler GitHub Scan
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
`--repository` scans a single repo. Use `--organization <name>` instead to include org-level checks (MFA, security policies, etc.). See the [GitHub provider authentication](/user-guide/providers/github/authentication) for required token permissions.
|
||||
</Info>
|
||||
|
||||
### Fail the PR on findings
|
||||
### Fail the PR on Findings
|
||||
|
||||
By default the action tolerates findings (exit code 3) and succeeds. Set `fail-on-findings: true` to fail the workflow step when Prowler detects findings. Combine with `--severity` to control which severity levels trigger the failure:
|
||||
|
||||
@@ -258,7 +258,7 @@ Scan results are written to `output/` in the workspace and uploaded as artifacts
|
||||
|
||||
When `upload-sarif` is enabled, SARIF results are also uploaded to GitHub Code Scanning and appear on the repository's **Security → Code scanning** tab, filtered by the branch that ran the scan.
|
||||
|
||||
### Step summary
|
||||
### Step Summary
|
||||
|
||||
The action writes a summary to the run page with a per-severity breakdown of failing checks, artifact and Code Scanning links, and (when `push-to-cloud: false`) a pointer to [Prowler Cloud](https://cloud.prowler.com) for continuous monitoring.
|
||||
|
||||
|
||||
@@ -124,6 +124,18 @@ To manually send individual Findings to Jira:
|
||||
|
||||

|
||||
|
||||
### Finding Reference in the Jira Issue
|
||||
|
||||
<VersionBadge version="5.41.0" />
|
||||
|
||||
Every Jira issue created from a single Finding carries a stable reference back to that Finding, so issues can be filtered, searched with Jira Query Language (JQL), or matched by automation:
|
||||
|
||||
* **Labels**: `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>` and `prowler-finding-<finding-uid>`. Labels are sanitized deterministically: whitespace becomes `_`, control characters are removed, and values are truncated to Jira's 255-character label limit.
|
||||
* **Finding URL**: a link that opens the Finding in Prowler, filtered by its unique identifier (UID) so it keeps working after later scans.
|
||||
* **Tenant Info**: the name of the Prowler organization that sent the Finding.
|
||||
|
||||
Prowler Cloud always includes the Finding URL. In Prowler Local Server, set `DJANGO_UI_BASE_URL` in the API environment (for example, `https://prowler.example.com`) to enable it. When the variable is empty, the issue is created without the link.
|
||||
|
||||
## Integration Status
|
||||
|
||||
Monitor and manage your Jira integrations through the management interface:
|
||||
@@ -159,13 +171,13 @@ Support for custom field mapping is planned for a future release.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection test fails
|
||||
### Connection Test Fails
|
||||
|
||||
* Verify Jira instance domain is correct and accessible
|
||||
* Confirm API token or credentials are valid
|
||||
* Ensure API access is enabled in Jira settings and the needed scopes are granted
|
||||
|
||||
### Check task status (API)
|
||||
### Check Task Status (API)
|
||||
|
||||
If the Jira issue does not appear in your Jira project, follow these steps to verify the export task status via the API.
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ The **Scope** column indicates where each permission applies. **All** means the
|
||||
</Note>
|
||||
To grant all administrative permissions, select the **Grant all admin permissions** option.
|
||||
|
||||
### Prowler Cloud exclusive permissions
|
||||
### Prowler Cloud Exclusive Permissions
|
||||
|
||||
The following permissions are available exclusively in **Prowler Cloud**:
|
||||
|
||||
|
||||
@@ -166,6 +166,6 @@ Once your scan has finished, you don’t need to grab the entire ZIP—just pull
|
||||
<Note>
|
||||
**API Note**
|
||||
|
||||
To fetch a single compliance report via API, see the Retrieve compliance report as CSV endpoint in the Prowler API Reference.[Prowler API Reference - Retrieve compliance report as CSV](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_compliance_retrieve)
|
||||
To fetch a single compliance report via API, see the Retrieve compliance report as CSV endpoint in the Prowler API Reference. [Prowler API Reference - Retrieve compliance report as CSV](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_compliance_retrieve)
|
||||
|
||||
</Note>
|
||||
|
||||
@@ -444,7 +444,7 @@ For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing).
|
||||
- The user associated with the API key lacks the **Manage Ingestions** permission
|
||||
- Contact the tenant administrator to grant the required permission
|
||||
|
||||
### Ingestion job status is "failed"
|
||||
### Ingestion Job Status Is "failed"
|
||||
|
||||
- Check the `/api/v1/ingestions/{id}/errors` endpoint for details
|
||||
- Verify the OCSF file format is valid
|
||||
|
||||
@@ -4,6 +4,24 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [0.11.0] (Prowler v5.40.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Failures shared by every tool - a rejected credential, a missing permission, a rate limit, an outage, an unreachable API, a bad argument - are now explained with a message that says what went wrong and what to do about it [(#12531)](https://github.com/prowler-cloud/prowler/pull/12531)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `prowler_docs_search` returns results again: it calls the search endpoint docs.prowler.com moved to, since the one it used no longer exists, and each result now names the page's title, the section it matched and a URL anchored at that section [(#12578)](https://github.com/prowler-cloud/prowler/pull/12578)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Stop relaying upstream response bodies to agents: a failed request now reaches the caller as a sentence this server wrote, with the full body kept to the logs, so a gateway error page or a debug traceback can no longer be replayed into a model's context [(#12531)](https://github.com/prowler-cloud/prowler/pull/12531)
|
||||
- `sqlite-libs` upgraded to 3.53.4-r0 in the container image, patching CVE-2026-11822 and CVE-2026-11824 [(#12537)](https://github.com/prowler-cloud/prowler/pull/12537)
|
||||
- `libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the container image, patching CVE-2026-14456 [(#12547)](https://github.com/prowler-cloud/prowler/pull/12547)
|
||||
|
||||
---
|
||||
|
||||
## [0.10.0] (Prowler v5.38.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Prowler App tools now report a failure as an MCP tool execution error (`isError: true`, explanation in `content`) instead of as a successful result carrying an `{"error": ...}` object, which clients and models read as a success
|
||||
@@ -0,0 +1 @@
|
||||
`prowler_get_compliance_framework_state_details` now rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider, which could report on a scan belonging to a different provider than the one that was asked about
|
||||
@@ -1 +0,0 @@
|
||||
`prowler_docs_search` returns results again: it calls the search endpoint docs.prowler.com moved to, since the one it used no longer exists, and each result now names the page's title, the section it matched and a URL anchored at that section
|
||||
@@ -0,0 +1 @@
|
||||
`prowler_docs_search` no longer reports a failed search as zero matches or an unreadable answer as a bad search term, and `prowler_docs_get_document` no longer reports a failed fetch as a missing page
|
||||
@@ -0,0 +1 @@
|
||||
`prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now report a check ID that belongs to another provider as such, naming that provider, instead of reporting the ID as one that does not exist
|
||||
@@ -1 +0,0 @@
|
||||
`libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the container image, patching CVE-2026-14456
|
||||
@@ -1 +0,0 @@
|
||||
`sqlite-libs` upgraded to 3.53.4-r0 in the container image, patching CVE-2026-11822 and CVE-2026-11824
|
||||
@@ -1 +0,0 @@
|
||||
Failures shared by every tool - a rejected credential, a missing permission, a rate limit, an outage, an unreachable API, a bad argument - are now explained with a message that says what went wrong and what to do about it
|
||||
@@ -1 +0,0 @@
|
||||
Stop relaying upstream response bodies to agents: a failed request now reaches the caller as a sentence this server wrote, with the full body kept to the logs, so a gateway error page or a debug traceback can no longer be replayed into a model's context
|
||||
@@ -19,10 +19,17 @@ class ProwlerAPIError(Exception):
|
||||
Attributes:
|
||||
status_code: HTTP status the API answered with
|
||||
detail: JSON:API `errors[0].detail`, None when there is none to trust
|
||||
payload: Parsed JSON body, for a tool that has to read the answer rather
|
||||
than only report it
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, message: str, status_code: int, *, detail: str | None = None
|
||||
self,
|
||||
message: str,
|
||||
status_code: int,
|
||||
*,
|
||||
detail: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code: int = status_code
|
||||
@@ -32,6 +39,12 @@ class ProwlerAPIError(Exception):
|
||||
# that must never be repeated to a model -- and None for a 5xx, see
|
||||
# `jsonapi_detail`.
|
||||
self.detail: str | None = detail
|
||||
# Not every error status means the request failed: Prowler answers 404
|
||||
# with the result itself when a query ran and matched nothing. A tool
|
||||
# reads this to tell such an answer apart from a real failure. It is the
|
||||
# upstream body, so it is read structurally and never relayed as text --
|
||||
# `detail` above is the only part of it that may be repeated to a model.
|
||||
self.payload: dict[str, Any] | None = payload
|
||||
|
||||
|
||||
class ProwlerAPIUnreachable(Exception):
|
||||
@@ -42,6 +55,59 @@ class ProwlerAPIInvalidResponse(Exception):
|
||||
"""The API answered, but with a body this server could not read as JSON."""
|
||||
|
||||
|
||||
class UpstreamInvalidResponse(Exception):
|
||||
"""An upstream this server reads directly answered with a body that is not JSON.
|
||||
|
||||
Raised in place of the `json.JSONDecodeError` httpx would otherwise let out.
|
||||
That one is a ValueError this module reads as a malformed argument, which is
|
||||
the opposite story: it sends a model off to fix a call that was fine.
|
||||
|
||||
Attributes:
|
||||
host: Host that answered, so the message can name what has to be fixed
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, host: str) -> None:
|
||||
super().__init__(message)
|
||||
self.host: str = host
|
||||
|
||||
|
||||
def parse_json_response(response: httpx.Response) -> Any:
|
||||
"""Parse an upstream answer as JSON, telling an unreadable body from a bad
|
||||
argument.
|
||||
|
||||
For every upstream a sub-server reads with an httpx client of its own --
|
||||
Prowler Hub, the documentation site. `httpx` lets a body it cannot decode
|
||||
out as a `json.JSONDecodeError`, which is a ValueError this module reads as
|
||||
a malformed argument. Coming from an upstream -- an HTML error page from an
|
||||
edge, a truncated body -- that is the wrong story, and the caller has no
|
||||
argument to fix.
|
||||
|
||||
The Prowler API client parses its own answers and raises
|
||||
`ProwlerAPIInvalidResponse` instead: it also carries writes, where an
|
||||
unreadable answer leaves the outcome unknown rather than merely absent.
|
||||
|
||||
Args:
|
||||
response: The answer to parse.
|
||||
|
||||
Returns:
|
||||
The parsed body.
|
||||
|
||||
Raises:
|
||||
UpstreamInvalidResponse: The body is not JSON.
|
||||
"""
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as e:
|
||||
# `.request` raises rather than returning None when it was never set.
|
||||
request = getattr(response, "_request", None)
|
||||
host = request.url.host if request is not None else "The upstream service"
|
||||
# Status only: the decoder's own message quotes the body it choked on,
|
||||
# and that body is the upstream text this server never relays.
|
||||
raise UpstreamInvalidResponse(
|
||||
f"{response.status_code} body is not JSON", host=host
|
||||
) from e
|
||||
|
||||
|
||||
def jsonapi_detail(response: httpx.Response) -> str | None:
|
||||
"""Return the API's own JSON:API error detail, when there is one to trust.
|
||||
|
||||
@@ -71,6 +137,10 @@ class InvalidArgument(ValueError):
|
||||
"""An argument this server rejected before any request went out."""
|
||||
|
||||
|
||||
class CredentialError(Exception):
|
||||
"""The credential the caller sent is missing, malformed or expired."""
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- messages
|
||||
|
||||
|
||||
@@ -154,6 +224,27 @@ def _describe_failure(exc: BaseException) -> str | None:
|
||||
"current state before sending it again."
|
||||
)
|
||||
|
||||
if isinstance(exc, UpstreamInvalidResponse):
|
||||
# The counterpart of the `json.JSONDecodeError` branch below: the same
|
||||
# decode failure is a malformed argument on one side of this server and
|
||||
# an upstream fault on the other, and only the type tells them apart.
|
||||
return (
|
||||
f"{exc.host} answered with a body this server could not read as JSON, "
|
||||
"so the call has no result to return. Nothing in the arguments caused "
|
||||
f"this and changing them will not help -- {exc.host} is answering with "
|
||||
"something other than the JSON it documents. Retry later."
|
||||
)
|
||||
|
||||
if isinstance(exc, CredentialError):
|
||||
# Not an argument problem, so it is worth saying that plainly: the
|
||||
# answer is a credential the user has to fix, not another attempt.
|
||||
return (
|
||||
f"This request carried no usable credential: {exc}. Retrying or "
|
||||
"changing the arguments will not help -- the client has to send an "
|
||||
"'Authorization: Bearer <token>' header holding a valid Prowler API "
|
||||
"key or an unexpired JWT."
|
||||
)
|
||||
|
||||
if isinstance(exc, ProwlerAPIUnreachable):
|
||||
# The only failure a model can turn into a duplicate write by repeating.
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Argument types shared by every tool in this server."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import StringConstraints
|
||||
|
||||
# The identifiers tools take -- a scan UUID, a query id, a Jira project key --
|
||||
# are required because there is nothing sensible to do without them. A model
|
||||
# that does not have one to hand tends to send an empty string rather than omit
|
||||
# the argument, and an empty string is not caught by "required": it travels into
|
||||
# a URL path or a request body and comes back as a 404 or an opaque API error
|
||||
# ("This field may not be blank") that says nothing about which argument was at
|
||||
# fault. Rejecting it here names the argument instead, and `minLength` puts the
|
||||
# constraint in the tool schema so a client can see it before calling.
|
||||
#
|
||||
# Whitespace is stripped first, so " abc " is accepted as "abc" and " " is
|
||||
# rejected like "".
|
||||
NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""URL construction shared by every sub-server.
|
||||
|
||||
An identifier joined into a path unencoded is not sent as itself: httpx resolves
|
||||
the URL per RFC 3986, so "../" walks the request onto another endpoint.
|
||||
"""
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
_DOT_SEGMENTS = frozenset({".", ".."})
|
||||
|
||||
|
||||
def path_segment(value: str) -> str:
|
||||
"""Encode one path segment, so an identifier names a resource and nothing else.
|
||||
|
||||
Args:
|
||||
value: The segment to encode, taken as a name in full.
|
||||
|
||||
Returns:
|
||||
The segment percent-encoded, with the dots escaped when it is only dots.
|
||||
"""
|
||||
encoded = quote(value, safe="")
|
||||
# A dot is legal in a name, so `quote` keeps it: a segment of nothing but
|
||||
# dots would still resolve away rather than name anything.
|
||||
return encoded.replace(".", "%2E") if encoded in _DOT_SEGMENTS else encoded
|
||||
|
||||
|
||||
def url_path(*segments: str) -> str:
|
||||
"""Build a URL path from one argument per segment, each of them encoded.
|
||||
|
||||
Args:
|
||||
*segments: The path segments, in order.
|
||||
|
||||
Returns:
|
||||
The joined path, with a leading slash.
|
||||
"""
|
||||
return "/" + "/".join(path_segment(segment) for segment in segments)
|
||||
@@ -354,6 +354,14 @@ class AttackPathQueryResult(MinimalSerializerMixin, BaseModel):
|
||||
relationships: list[AttackPathsGraphRelationship] = Field(
|
||||
default_factory=list, description="Relationships connecting the nodes"
|
||||
)
|
||||
# A graph with nothing in it serializes to `{}`, since the mixin drops empty
|
||||
# lists. That reads as an answer that went missing rather than as the finding
|
||||
# it is -- the query ran and this account has no such attack path -- so the
|
||||
# empty case carries a sentence saying so.
|
||||
message: str | None = Field(
|
||||
default=None,
|
||||
description="Present only when the query matched nothing, to say the query ran and found no attack path rather than leaving an empty result to interpret",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(
|
||||
@@ -368,7 +376,15 @@ class AttackPathQueryResult(MinimalSerializerMixin, BaseModel):
|
||||
Returns:
|
||||
AttackPathQueryResult with parsed data and summary
|
||||
"""
|
||||
attributes = response.get("data", {}).get("attributes")
|
||||
data = response.get("data")
|
||||
attributes = data.get("attributes") if data is not None else None
|
||||
# Prowler spells a graph with nothing in it either as empty lists or as
|
||||
# a null `attributes`. Both say the same thing -- the query ran and
|
||||
# matched nothing -- so the null reads as the empty graph it stands for
|
||||
# instead of crashing the parse.
|
||||
if attributes is None:
|
||||
attributes = {}
|
||||
|
||||
nodes_data = attributes.get("nodes", [])
|
||||
relationships_data = attributes.get("relationships", [])
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
|
||||
|
||||
@@ -104,6 +104,29 @@ class ProvidersListResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ProviderDeletionResult(MinimalSerializerMixin, BaseModel):
|
||||
"""Outcome of a provider deletion.
|
||||
|
||||
Prowler deletes a provider in a background task, so the answer is not always
|
||||
a finished deletion. A deletion that never started is raised as an error
|
||||
instead of being reported here: this model only describes a deletion Prowler
|
||||
accepted and began.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
status: Literal["deleted", "in_progress"] = Field(
|
||||
description="Outcome of the deletion: 'deleted' when Prowler finished removing the provider, 'in_progress' when the background task was accepted and is still running, which is normal for a provider with many scans and findings"
|
||||
)
|
||||
task_id: str | None = Field(
|
||||
default=None,
|
||||
description="UUIDv4 of the background deletion task, present when the deletion did not finish within the polling window so its state can be checked later",
|
||||
)
|
||||
message: str = Field(
|
||||
description="Human-readable description of what happened and what to do next"
|
||||
)
|
||||
|
||||
|
||||
class ProviderConnectionStatus(MinimalSerializerMixin, BaseModel):
|
||||
"""Result of provider connection operation."""
|
||||
|
||||
|
||||
@@ -191,18 +191,18 @@ class ScansListResponse(BaseModel):
|
||||
|
||||
|
||||
class ScanCreationResult(MinimalSerializerMixin, BaseModel):
|
||||
"""Result of scan creation operation.
|
||||
"""Result of a scan creation that succeeded.
|
||||
|
||||
Used by trigger_scan() to communicate the outcome of scan creation.
|
||||
Status indicates whether scan was created successfully or failed.
|
||||
Used by trigger_scan(). A scan that was not created leaves the tool as an
|
||||
error instead of being reported here, so this model only ever describes a
|
||||
scan that exists -- which is why it carries no success flag: a field with
|
||||
one reachable value says nothing, and inviting a reader to branch on it
|
||||
suggests there is a failure shape to look for here. There is not; the
|
||||
failure is the error.
|
||||
"""
|
||||
|
||||
scan: DetailedScan | None = Field(
|
||||
default=None,
|
||||
description="Detailed scan information if creation succeeded, None otherwise",
|
||||
)
|
||||
status: Literal["success", "failed"] = Field(
|
||||
description="Outcome of scan creation: success (scan created successfully) or failed (error)"
|
||||
scan: DetailedScan = Field(
|
||||
description="Detailed information about the scan that was created"
|
||||
)
|
||||
message: str = Field(
|
||||
description="Human-readable message describing the scan creation result"
|
||||
@@ -210,13 +210,26 @@ class ScanCreationResult(MinimalSerializerMixin, BaseModel):
|
||||
|
||||
|
||||
class ScheduleCreationResult(MinimalSerializerMixin, BaseModel):
|
||||
"""Result of async schedule creation operation.
|
||||
"""Result of a daily schedule creation that succeeded.
|
||||
|
||||
Used by schedule_daily_scan() to communicate scheduling outcome.
|
||||
Used by schedule_daily_scan(). Prowler commits the schedule inside the
|
||||
request that creates it, so an answer means it exists; a provider that
|
||||
already has one is refused with a 409 and leaves the tool as an error. That
|
||||
leaves nothing for a success flag to distinguish, so there is none.
|
||||
"""
|
||||
|
||||
scheduled: bool = Field(
|
||||
description="Whether the daily scan schedule was created successfully"
|
||||
first_run_state: (
|
||||
Literal[
|
||||
"available", "scheduled", "executing", "completed", "failed", "cancelled"
|
||||
]
|
||||
| None
|
||||
) = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"State of the first scan Prowler starts immediately alongside the schedule. "
|
||||
"This describes that one run, not the recurring schedule, which stands "
|
||||
"regardless of it"
|
||||
),
|
||||
)
|
||||
message: str = Field(
|
||||
description="Human-readable message describing the scheduling result"
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastmcp import FastMCP
|
||||
from prowler_mcp_server.prowler_app.utils.tool_loader import load_all_tools
|
||||
|
||||
# Initialize MCP server
|
||||
app_mcp_server = FastMCP("prowler-app")
|
||||
app_mcp_server = FastMCP("prowler-app", mask_error_details=True)
|
||||
|
||||
# Auto-discover and load all tools from the tools package
|
||||
load_all_tools(app_mcp_server)
|
||||
|
||||
@@ -7,8 +7,11 @@ through cloud infrastructure relationships.
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.errors import ProwlerAPIError
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.attack_paths import (
|
||||
AttackPathCartographySchema,
|
||||
AttackPathQuery,
|
||||
@@ -76,50 +79,47 @@ class AttackPathsTools(BaseTool):
|
||||
2. Use prowler_list_attack_paths_queries to see available queries for a scan
|
||||
3. Use prowler_run_attack_paths_query to execute analysis
|
||||
"""
|
||||
try:
|
||||
# Validate pagination
|
||||
self.api_client.validate_page_size(page_size)
|
||||
# Validate pagination
|
||||
self.api_client.validate_page_size(page_size)
|
||||
|
||||
# Build query parameters
|
||||
params: dict[str, Any] = {
|
||||
"page[size]": page_size,
|
||||
"page[number]": page_number,
|
||||
}
|
||||
# Build query parameters
|
||||
params: dict[str, Any] = {
|
||||
"page[size]": page_size,
|
||||
"page[number]": page_number,
|
||||
}
|
||||
|
||||
# Apply provider filters
|
||||
if provider_id:
|
||||
params["filter[provider__in]"] = provider_id
|
||||
if provider_type:
|
||||
params["filter[provider_type__in]"] = provider_type
|
||||
# Apply provider filters
|
||||
if provider_id:
|
||||
params["filter[provider__in]"] = provider_id
|
||||
if provider_type:
|
||||
params["filter[provider_type__in]"] = provider_type
|
||||
|
||||
# Apply state filter
|
||||
if state:
|
||||
params["filter[state__in]"] = state
|
||||
# Apply state filter
|
||||
if state:
|
||||
params["filter[state__in]"] = state
|
||||
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
|
||||
api_response = await self.api_client.get(
|
||||
"/attack-paths-scans", params=clean_params
|
||||
)
|
||||
simplified_response = AttackPathScansListResponse.from_api_response(
|
||||
api_response
|
||||
)
|
||||
api_response = await self.api_client.get(
|
||||
"/attack-paths-scans", params=clean_params
|
||||
)
|
||||
simplified_response = AttackPathScansListResponse.from_api_response(
|
||||
api_response
|
||||
)
|
||||
|
||||
return simplified_response.model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to list attack paths scans: {e}")
|
||||
return {"error": f"Failed to list attack paths scans: {str(e)}"}
|
||||
return simplified_response.model_dump()
|
||||
|
||||
async def list_attack_paths_queries(
|
||||
self,
|
||||
scan_id: str = Field(
|
||||
description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs"
|
||||
scan_id: NonBlankStr = Field(
|
||||
description="UUID of a COMPLETED attack paths scan, as returned by `prowler_list_attack_paths_scans` with state=['completed']. This is NOT a regular scan ID: an ID from `prowler_search_scans` or `prowler_get_scan` names a different resource and is rejected here"
|
||||
),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Discover available Attack Paths queries for a completed scan.
|
||||
|
||||
IMPORTANT: The scan must be in 'completed' state to list queries.
|
||||
Queries are provider-specific
|
||||
Attack Paths covers AWS providers only, so only an AWS provider has an
|
||||
Attack Paths scan to name here, and every query is an AWS one.
|
||||
|
||||
Each query includes:
|
||||
- id: Query identifier to use with run_attack_paths_query
|
||||
@@ -141,23 +141,32 @@ class AttackPathsTools(BaseTool):
|
||||
api_response = await self.api_client.get(
|
||||
f"/attack-paths-scans/{scan_id}/queries"
|
||||
)
|
||||
except ProwlerAPIError as e:
|
||||
# A 404 here is Prowler failing to resolve `scan_id` to an Attack
|
||||
# Paths scan, and its own reason for it -- a bare "Not found." --
|
||||
# does not say what kind of ID it was looking for. The mistake it
|
||||
# stands for is a regular scan ID: an Attack Paths scan is a separate
|
||||
# resource with IDs of its own, and Prowler only creates one for an
|
||||
# AWS provider, so a scan of any other provider has none to pass.
|
||||
#
|
||||
# The endpoint answers 404 for a second thing -- a provider type with
|
||||
# no query catalog -- but that one cannot happen: a scan only exists
|
||||
# where Attack Paths runs, which is AWS, and AWS has a catalog.
|
||||
if e.status_code == 404:
|
||||
raise self._unknown_scan_error(scan_id)
|
||||
raise
|
||||
|
||||
return [
|
||||
AttackPathQuery.from_api_response(query).model_dump()
|
||||
for query in api_response.get("data", [])
|
||||
]
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to list attack paths queries for scan {scan_id}: {e}"
|
||||
)
|
||||
return [{"error": f"Failed to list attack paths queries: {str(e)}"}]
|
||||
return [
|
||||
AttackPathQuery.from_api_response(query).model_dump()
|
||||
for query in api_response.get("data", [])
|
||||
]
|
||||
|
||||
async def run_attack_paths_query(
|
||||
self,
|
||||
scan_id: str = Field(
|
||||
scan_id: NonBlankStr = Field(
|
||||
description="UUID of a COMPLETED attack paths scan. The scan must be in 'completed' state"
|
||||
),
|
||||
query_id: str = Field(
|
||||
query_id: NonBlankStr = Field(
|
||||
description="Query ID to execute (e.g., 'aws-internet-exposed-ec2-sensitive-s3-access'). Use `prowler_list_attack_paths_queries` to discover available queries"
|
||||
),
|
||||
parameters: dict[str, str] = Field(
|
||||
@@ -198,39 +207,61 @@ class AttackPathsTools(BaseTool):
|
||||
3. Execute this tool with appropriate parameters
|
||||
4. Analyze the returned graph for security insights
|
||||
"""
|
||||
try:
|
||||
# Build the request payload following JSON:API format
|
||||
request_data: dict[str, Any] = {
|
||||
"data": {
|
||||
"type": "attack-paths-query-run-requests",
|
||||
"attributes": {
|
||||
"id": query_id,
|
||||
},
|
||||
# Build the request payload following JSON:API format
|
||||
request_data: dict[str, Any] = {
|
||||
"data": {
|
||||
"type": "attack-paths-query-run-requests",
|
||||
"attributes": {
|
||||
"id": query_id,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# Add parameters if provided
|
||||
if parameters:
|
||||
request_data["data"]["attributes"]["parameters"] = parameters
|
||||
# Add parameters if provided
|
||||
if parameters:
|
||||
request_data["data"]["attributes"]["parameters"] = parameters
|
||||
|
||||
try:
|
||||
api_response = await self.api_client.post(
|
||||
f"/attack-paths-scans/{scan_id}/queries/run",
|
||||
json_data=request_data,
|
||||
)
|
||||
except ProwlerAPIError as e:
|
||||
# Prowler answers a query that matched nothing with 404 and the empty
|
||||
# result as the body. That is an answer -- this account has no such
|
||||
# attack path, which is the good outcome -- so it is returned rather
|
||||
# than raised: reporting it as a failure invites a retry of a call
|
||||
# whose arguments were right, and hides a clean result.
|
||||
if e.status_code == 404 and isinstance(e.payload, dict):
|
||||
if "data" in e.payload:
|
||||
api_response = e.payload
|
||||
else:
|
||||
# No result body, so `scan_id` did not resolve to an Attack
|
||||
# Paths scan. An unknown query_id is a 400, not this.
|
||||
raise self._unknown_scan_error(scan_id)
|
||||
else:
|
||||
raise
|
||||
|
||||
# Parse the response
|
||||
query_result = AttackPathQueryResult.from_api_response(api_response)
|
||||
# Parse the response
|
||||
query_result = AttackPathQueryResult.from_api_response(api_response)
|
||||
|
||||
return query_result.model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to run attack paths query '{query_id}' on scan {scan_id}: {e}"
|
||||
if not query_result.nodes:
|
||||
query_result = query_result.model_copy(
|
||||
update={
|
||||
"message": (
|
||||
f"The query '{query_id}' ran against scan {scan_id} and matched "
|
||||
"nothing, so this provider has no attack path of that shape. "
|
||||
"The scan and the query ID were both valid; running it again "
|
||||
"will return the same thing."
|
||||
)
|
||||
}
|
||||
)
|
||||
return {"error": f"Failed to run attack paths query '{query_id}': {str(e)}"}
|
||||
|
||||
return query_result.model_dump()
|
||||
|
||||
async def get_attack_paths_cartography_schema(
|
||||
self,
|
||||
scan_id: str = Field(
|
||||
scan_id: NonBlankStr = Field(
|
||||
description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -262,18 +293,43 @@ class AttackPathsTools(BaseTool):
|
||||
api_response = await self.api_client.get(
|
||||
f"/attack-paths-scans/{scan_id}/schema"
|
||||
)
|
||||
except ProwlerAPIError as e:
|
||||
# Two 404s again, told apart by whether Prowler wrote a JSON:API
|
||||
# error. Absent means the scan resolved and its graph simply records
|
||||
# no Cartography module, so the ID is not the thing to change.
|
||||
if e.status_code == 404:
|
||||
if e.detail is None:
|
||||
raise ToolError(
|
||||
f"Scan {scan_id} has no Cartography schema recorded, so there is "
|
||||
"nothing to write custom queries against. Use "
|
||||
"prowler_list_attack_paths_queries for the ready-made queries of "
|
||||
"this scan, which do not need the schema."
|
||||
)
|
||||
else:
|
||||
raise self._unknown_scan_error(scan_id)
|
||||
raise
|
||||
|
||||
schema = AttackPathCartographySchema.from_api_response(api_response)
|
||||
schema = AttackPathCartographySchema.from_api_response(api_response)
|
||||
|
||||
schema_content = await self.api_client.fetch_external_url(
|
||||
schema.raw_schema_url
|
||||
)
|
||||
schema_content = await self.api_client.fetch_external_url(schema.raw_schema_url)
|
||||
|
||||
return schema.model_copy(
|
||||
update={"schema_content": schema_content}
|
||||
).model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to get cartography schema for scan {scan_id}: {e}"
|
||||
)
|
||||
return {"error": f"Failed to get cartography schema: {str(e)}"}
|
||||
return schema.model_copy(update={"schema_content": schema_content}).model_dump()
|
||||
|
||||
# Private helper methods
|
||||
|
||||
@staticmethod
|
||||
def _unknown_scan_error(scan_id: str) -> ToolError:
|
||||
"""Describe a scan ID Prowler could not resolve to an Attack Paths scan.
|
||||
|
||||
Returns:
|
||||
The ``ToolError`` for the caller to raise. Built without a ``from``
|
||||
clause on purpose: the sentence is the final word, not a wrapper
|
||||
around the API's.
|
||||
"""
|
||||
return ToolError(
|
||||
f"Prowler has no Attack Paths scan with ID {scan_id}. These are a "
|
||||
"different resource from regular scans and only exist for AWS "
|
||||
"providers, so an ID from prowler_search_scans or prowler_get_scan "
|
||||
"never resolves here. Use prowler_list_attack_paths_scans to get an "
|
||||
"ID these tools take."
|
||||
)
|
||||
|
||||
@@ -6,8 +6,11 @@ across all cloud providers.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.errors import InvalidArgument
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.compliance import (
|
||||
ComplianceFrameworksListResponse,
|
||||
ComplianceRequirementAttributesListResponse,
|
||||
@@ -34,7 +37,7 @@ class ComplianceTools(BaseTool):
|
||||
The scan_id of the latest completed scan for the provider.
|
||||
|
||||
Raises:
|
||||
ValueError: If no completed scans are found for the provider.
|
||||
ToolError: If no completed scans are found for the provider
|
||||
"""
|
||||
scan_params = {
|
||||
"filter[provider]": provider_id,
|
||||
@@ -48,7 +51,7 @@ class ComplianceTools(BaseTool):
|
||||
|
||||
scans_data = scans_response.get("data", [])
|
||||
if not scans_data:
|
||||
raise ValueError(
|
||||
raise ToolError(
|
||||
f"No completed scans found for provider {provider_id}. "
|
||||
"Run a scan first using prowler_trigger_scan."
|
||||
)
|
||||
@@ -93,18 +96,15 @@ class ComplianceTools(BaseTool):
|
||||
2. Use prowler_get_compliance_framework_state_details with a specific compliance_id to see which requirements failed
|
||||
"""
|
||||
if not scan_id and not provider_id:
|
||||
return {
|
||||
"error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
|
||||
}
|
||||
raise InvalidArgument(
|
||||
"Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
|
||||
)
|
||||
elif scan_id and provider_id:
|
||||
return {
|
||||
"error": "Provide either scan_id or provider_id, not both. To get compliance data for a specific scan, use scan_id. To get data for the latest scan of a provider, use provider_id."
|
||||
}
|
||||
raise InvalidArgument(
|
||||
"Provide either scan_id or provider_id, not both. To get compliance data for a specific scan, use scan_id. To get data for the latest scan of a provider, use provider_id."
|
||||
)
|
||||
elif not scan_id and provider_id:
|
||||
try:
|
||||
scan_id = await self._get_latest_scan_id_for_provider(provider_id)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
scan_id = await self._get_latest_scan_id_for_provider(provider_id)
|
||||
|
||||
params: dict[str, Any] = {"filter[scan_id]": scan_id}
|
||||
|
||||
@@ -253,16 +253,16 @@ class ComplianceTools(BaseTool):
|
||||
|
||||
async def get_compliance_framework_state_details(
|
||||
self,
|
||||
compliance_id: str = Field(
|
||||
compliance_id: NonBlankStr = Field(
|
||||
description="Compliance framework ID to get details for (e.g., 'cis_1.5_aws', 'pci_dss_v4.0_aws'). You can get compliance IDs from prowler_get_compliance_overview or consulting Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server",
|
||||
),
|
||||
scan_id: str | None = Field(
|
||||
default=None,
|
||||
description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified.",
|
||||
description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified. Do not pass it together with provider_id.",
|
||||
),
|
||||
provider_id: str | None = Field(
|
||||
default=None,
|
||||
description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs.",
|
||||
description="Prowler's internal UUID (v4) for a specific provider. The tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs. Do not pass it together with scan_id.",
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Get detailed requirement-level breakdown for a specific compliance framework.
|
||||
@@ -283,8 +283,8 @@ class ComplianceTools(BaseTool):
|
||||
- Use prowler_get_finding_details with these finding IDs for more details and remediation guidance
|
||||
|
||||
Default behavior:
|
||||
- Requires either scan_id OR provider_id
|
||||
- With provider_id (no scan_id): Automatically finds the latest completed scan for that provider
|
||||
- Requires exactly one of scan_id OR provider_id; providing both is rejected
|
||||
- With provider_id: Automatically finds the latest completed scan for that provider
|
||||
- With scan_id: Uses that specific scan's compliance data
|
||||
- Only shows failed requirements with their associated failed finding IDs
|
||||
|
||||
@@ -293,21 +293,22 @@ class ComplianceTools(BaseTool):
|
||||
2. Use this tool with the compliance_id to see failed requirements and their finding IDs
|
||||
3. Use prowler_get_finding_details with the finding IDs to get remediation guidance
|
||||
"""
|
||||
# Validate that either scan_id or provider_id is provided
|
||||
# Exactly one of the two: taking scan_id and ignoring provider_id would
|
||||
# answer for whatever provider that scan belongs to, which is not
|
||||
# necessarily the one the caller named.
|
||||
if not scan_id and not provider_id:
|
||||
return {
|
||||
"error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
|
||||
}
|
||||
raise InvalidArgument(
|
||||
"Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
|
||||
)
|
||||
elif scan_id and provider_id:
|
||||
raise InvalidArgument(
|
||||
"Provide either scan_id or provider_id, not both. To get compliance data for a specific scan, use scan_id. To get data for the latest scan of a provider, use provider_id."
|
||||
)
|
||||
|
||||
# Resolve provider_id to latest scan_id if needed
|
||||
resolved_scan_id = scan_id
|
||||
if not scan_id and provider_id:
|
||||
try:
|
||||
resolved_scan_id = await self._get_latest_scan_id_for_provider(
|
||||
provider_id
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
resolved_scan_id = await self._get_latest_scan_id_for_provider(provider_id)
|
||||
|
||||
# Build params for requirements endpoint
|
||||
params: dict[str, Any] = {
|
||||
|
||||
@@ -6,8 +6,10 @@ This module provides read-only tools for finding group triage and drill-downs.
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.finding_groups import (
|
||||
DetailedFindingGroup,
|
||||
FindingGroupResourcesListResponse,
|
||||
@@ -236,50 +238,46 @@ class FindingGroupsTools(BaseTool):
|
||||
prowler_get_finding_group_details for complete counters or
|
||||
prowler_list_finding_group_resources to drill into affected resources.
|
||||
"""
|
||||
try:
|
||||
self.api_client.validate_page_size(page_size)
|
||||
date_range, params = self._base_date_params(date_from, date_to)
|
||||
endpoint = self._group_endpoint(date_range)
|
||||
self.api_client.validate_page_size(page_size)
|
||||
date_range, params = self._base_date_params(date_from, date_to)
|
||||
endpoint = self._group_endpoint(date_range)
|
||||
|
||||
self._apply_common_filters(
|
||||
params,
|
||||
provider,
|
||||
provider_type,
|
||||
provider_uid,
|
||||
provider_alias,
|
||||
region,
|
||||
service,
|
||||
resource_type,
|
||||
resource_name,
|
||||
resource_uid,
|
||||
resource_group,
|
||||
category,
|
||||
check_id,
|
||||
check_title,
|
||||
severity,
|
||||
status,
|
||||
muted,
|
||||
delta,
|
||||
)
|
||||
self._apply_common_filters(
|
||||
params,
|
||||
provider,
|
||||
provider_type,
|
||||
provider_uid,
|
||||
provider_alias,
|
||||
region,
|
||||
service,
|
||||
resource_type,
|
||||
resource_name,
|
||||
resource_uid,
|
||||
resource_group,
|
||||
category,
|
||||
check_id,
|
||||
check_title,
|
||||
severity,
|
||||
status,
|
||||
muted,
|
||||
delta,
|
||||
)
|
||||
|
||||
params["filter[include_muted]"] = self._bool_value(include_muted)
|
||||
params["page[size]"] = page_size
|
||||
params["page[number]"] = page_number
|
||||
params["fields[finding-groups]"] = GROUP_LIST_FIELDS
|
||||
if sort:
|
||||
params["sort"] = sort
|
||||
params["filter[include_muted]"] = self._bool_value(include_muted)
|
||||
params["page[size]"] = page_size
|
||||
params["page[number]"] = page_number
|
||||
params["fields[finding-groups]"] = GROUP_LIST_FIELDS
|
||||
if sort:
|
||||
params["sort"] = sort
|
||||
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
api_response = await self.api_client.get(endpoint, params=clean_params)
|
||||
response = FindingGroupsListResponse.from_api_response(api_response)
|
||||
return response.model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error listing finding groups: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
api_response = await self.api_client.get(endpoint, params=clean_params)
|
||||
response = FindingGroupsListResponse.from_api_response(api_response)
|
||||
return response.model_dump()
|
||||
|
||||
async def get_finding_group_details(
|
||||
self,
|
||||
check_id: str = Field(
|
||||
check_id: NonBlankStr = Field(
|
||||
description="Public check ID that identifies the finding group. This is not a UUID."
|
||||
),
|
||||
date_from: str | None = Field(
|
||||
@@ -297,39 +295,37 @@ class FindingGroupsTools(BaseTool):
|
||||
or historical data when dates are provided. Fully muted groups are
|
||||
included by default so accepted risk does not look like a missing group.
|
||||
"""
|
||||
try:
|
||||
date_range, params = self._base_date_params(date_from, date_to)
|
||||
endpoint = self._group_endpoint(date_range)
|
||||
date_range, params = self._base_date_params(date_from, date_to)
|
||||
endpoint = self._group_endpoint(date_range)
|
||||
|
||||
params.update(
|
||||
{
|
||||
"filter[check_id]": check_id,
|
||||
"filter[include_muted]": True,
|
||||
"page[size]": 1,
|
||||
"page[number]": 1,
|
||||
"fields[finding-groups]": GROUP_DETAIL_FIELDS,
|
||||
}
|
||||
params.update(
|
||||
{
|
||||
"filter[check_id]": check_id,
|
||||
"filter[include_muted]": True,
|
||||
"page[size]": 1,
|
||||
"page[number]": 1,
|
||||
"fields[finding-groups]": GROUP_DETAIL_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
api_response = await self.api_client.get(endpoint, params=clean_params)
|
||||
data = api_response.get("data", [])
|
||||
|
||||
if not data:
|
||||
# No `from`: this names the check and the tool that lists valid ones,
|
||||
# neither of which the shared classifier can know.
|
||||
raise ToolError(
|
||||
f"No finding group exists for check '{check_id}' in this scan. Use "
|
||||
"prowler_list_finding_groups to see the checks that have findings."
|
||||
)
|
||||
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
api_response = await self.api_client.get(endpoint, params=clean_params)
|
||||
data = api_response.get("data", [])
|
||||
|
||||
if not data:
|
||||
return {
|
||||
"error": f"Finding group '{check_id}' not found.",
|
||||
"status": "not_found",
|
||||
}
|
||||
|
||||
group = DetailedFindingGroup.from_api_response(data[0])
|
||||
return group.model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting finding group details: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
group = DetailedFindingGroup.from_api_response(data[0])
|
||||
return group.model_dump()
|
||||
|
||||
async def list_finding_group_resources(
|
||||
self,
|
||||
check_id: str = Field(
|
||||
check_id: NonBlankStr = Field(
|
||||
description="Public check ID that identifies the finding group. This is not a UUID."
|
||||
),
|
||||
provider: list[str] = Field(
|
||||
@@ -426,45 +422,41 @@ class FindingGroupsTools(BaseTool):
|
||||
`finding_id`. Use `prowler_get_finding_details(finding_id)` to
|
||||
retrieve complete remediation guidance for a specific resource finding.
|
||||
"""
|
||||
try:
|
||||
self.api_client.validate_page_size(page_size)
|
||||
date_range, params = self._base_date_params(date_from, date_to)
|
||||
endpoint = self._resource_endpoint(check_id, date_range)
|
||||
self.api_client.validate_page_size(page_size)
|
||||
date_range, params = self._base_date_params(date_from, date_to)
|
||||
endpoint = self._resource_endpoint(check_id, date_range)
|
||||
|
||||
if muted is None and not self._bool_value(include_muted):
|
||||
muted = False
|
||||
if muted is None and not self._bool_value(include_muted):
|
||||
muted = False
|
||||
|
||||
self._apply_common_filters(
|
||||
params,
|
||||
provider,
|
||||
provider_type,
|
||||
provider_uid,
|
||||
provider_alias,
|
||||
region,
|
||||
service,
|
||||
resource_type,
|
||||
resource_name,
|
||||
resource_uid,
|
||||
resource_group,
|
||||
category,
|
||||
[],
|
||||
None,
|
||||
severity,
|
||||
status,
|
||||
muted,
|
||||
delta,
|
||||
)
|
||||
self._apply_common_filters(
|
||||
params,
|
||||
provider,
|
||||
provider_type,
|
||||
provider_uid,
|
||||
provider_alias,
|
||||
region,
|
||||
service,
|
||||
resource_type,
|
||||
resource_name,
|
||||
resource_uid,
|
||||
resource_group,
|
||||
category,
|
||||
[],
|
||||
None,
|
||||
severity,
|
||||
status,
|
||||
muted,
|
||||
delta,
|
||||
)
|
||||
|
||||
params["page[size]"] = page_size
|
||||
params["page[number]"] = page_number
|
||||
params["fields[finding-group-resources]"] = RESOURCE_FIELDS
|
||||
if sort:
|
||||
params["sort"] = sort
|
||||
params["page[size]"] = page_size
|
||||
params["page[number]"] = page_number
|
||||
params["fields[finding-group-resources]"] = RESOURCE_FIELDS
|
||||
if sort:
|
||||
params["sort"] = sort
|
||||
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
api_response = await self.api_client.get(endpoint, params=clean_params)
|
||||
response = FindingGroupResourcesListResponse.from_api_response(api_response)
|
||||
return response.model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error listing finding group resources: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
api_response = await self.api_client.get(endpoint, params=clean_params)
|
||||
response = FindingGroupResourcesListResponse.from_api_response(api_response)
|
||||
return response.model_dump()
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.findings import (
|
||||
DetailedFinding,
|
||||
FindingsListResponse,
|
||||
@@ -180,7 +181,7 @@ class FindingsTools(BaseTool):
|
||||
|
||||
async def get_finding_details(
|
||||
self,
|
||||
finding_id: str = Field(
|
||||
finding_id: NonBlankStr = Field(
|
||||
description="UUID of the finding to retrieve (must be a valid UUID format, e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac'). Returns an error if the finding ID is invalid or not found."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -9,8 +9,11 @@ This module provides tools for managing where Prowler sends its results, includi
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.errors import CredentialError, InvalidArgument
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.integrations import (
|
||||
DetailedIntegration,
|
||||
IntegrationConnectionStatus,
|
||||
@@ -126,7 +129,7 @@ class IntegrationsTools(BaseTool):
|
||||
|
||||
async def get_integration(
|
||||
self,
|
||||
integration_id: str = Field(
|
||||
integration_id: NonBlankStr = Field(
|
||||
description="UUID of the integration to retrieve. Must be a valid UUID format (e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac'). Use prowler_list_integrations to find it."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -157,7 +160,7 @@ class IntegrationsTools(BaseTool):
|
||||
|
||||
async def create_amazon_s3_integration(
|
||||
self,
|
||||
bucket_name: str = Field(
|
||||
bucket_name: NonBlankStr = Field(
|
||||
description="Name of the S3 bucket where Prowler will upload the scan outputs (CSV, HTML, OCSF JSON and compliance reports)."
|
||||
),
|
||||
output_directory: str = Field(
|
||||
@@ -168,15 +171,15 @@ class IntegrationsTools(BaseTool):
|
||||
default=[],
|
||||
description="Prowler UUIDs of the providers whose scan outputs are exported to this bucket. Use prowler_search_providers to find them. Leave empty to attach no provider yet.",
|
||||
),
|
||||
role_arn: str | None = Field(
|
||||
role_arn: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="ARN of the IAM role Prowler assumes to write to the bucket (e.g. 'arn:aws:iam::123456789012:role/ProwlerS3Integration'). Recommended over static keys.",
|
||||
),
|
||||
external_id: str | None = Field(
|
||||
external_id: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="External ID required by the trust policy of the assumed role. In Prowler Cloud this is the tenant ID.",
|
||||
),
|
||||
role_session_name: str | None = Field(
|
||||
role_session_name: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="Identifier for the role session, useful to track it in AWS logs. Only letters, digits and the characters =,.@_- are allowed.",
|
||||
),
|
||||
@@ -184,15 +187,15 @@ class IntegrationsTools(BaseTool):
|
||||
default=3600,
|
||||
description="Duration of the assumed role session in seconds. Must be between 900 and 43200. Defaults to 3600 when omitted.",
|
||||
),
|
||||
aws_access_key_id: str | None = Field(
|
||||
aws_access_key_id: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="AWS access key ID. Only needed when the Prowler deployment has no ambient AWS credentials.",
|
||||
),
|
||||
aws_secret_access_key: str | None = Field(
|
||||
aws_secret_access_key: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="AWS secret access key. Required when 'aws_access_key_id' is provided.",
|
||||
),
|
||||
aws_session_token: str | None = Field(
|
||||
aws_session_token: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="AWS session token, only for temporary credentials.",
|
||||
),
|
||||
@@ -244,34 +247,30 @@ class IntegrationsTools(BaseTool):
|
||||
"""
|
||||
self.logger.info(f"Creating Amazon S3 integration for bucket {bucket_name}...")
|
||||
|
||||
try:
|
||||
credentials = self._build_aws_credentials(
|
||||
role_arn=role_arn,
|
||||
external_id=external_id,
|
||||
role_session_name=role_session_name,
|
||||
session_duration=session_duration,
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
aws_session_token=aws_session_token,
|
||||
)
|
||||
credentials = self._build_aws_credentials(
|
||||
role_arn=role_arn,
|
||||
external_id=external_id,
|
||||
role_session_name=role_session_name,
|
||||
session_duration=session_duration,
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
aws_session_token=aws_session_token,
|
||||
)
|
||||
|
||||
return await self._create_integration(
|
||||
integration_type="amazon_s3",
|
||||
configuration={
|
||||
"bucket_name": bucket_name,
|
||||
"output_directory": output_directory,
|
||||
},
|
||||
credentials=credentials,
|
||||
provider_ids=provider_ids,
|
||||
enabled=enabled,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Amazon S3 integration creation failed: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
return await self._create_integration(
|
||||
integration_type="amazon_s3",
|
||||
configuration={
|
||||
"bucket_name": bucket_name,
|
||||
"output_directory": output_directory,
|
||||
},
|
||||
credentials=credentials,
|
||||
provider_ids=provider_ids,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
async def create_aws_security_hub_integration(
|
||||
self,
|
||||
provider_id: str = Field(
|
||||
provider_id: NonBlankStr = Field(
|
||||
description="Prowler UUID of the AWS provider whose findings are sent to Security Hub. It must be an AWS provider, and it can only have one Security Hub integration. Use prowler_search_providers with provider_type=['aws'] to find it."
|
||||
),
|
||||
send_only_fails: bool = Field(
|
||||
@@ -282,15 +281,15 @@ class IntegrationsTools(BaseTool):
|
||||
default=False,
|
||||
description="When true, findings that are no longer present in the latest scan are archived in Security Hub.",
|
||||
),
|
||||
role_arn: str | None = Field(
|
||||
role_arn: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="ARN of a dedicated IAM role Prowler assumes to write to Security Hub. Leave every credential parameter empty to reuse the credentials already stored for the provider, which is the recommended setup.",
|
||||
),
|
||||
external_id: str | None = Field(
|
||||
external_id: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="External ID required by the trust policy of the assumed role.",
|
||||
),
|
||||
role_session_name: str | None = Field(
|
||||
role_session_name: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="Identifier for the role session, useful to track it in AWS logs. Only letters, digits and the characters =,.@_- are allowed.",
|
||||
),
|
||||
@@ -298,14 +297,14 @@ class IntegrationsTools(BaseTool):
|
||||
default=None,
|
||||
description="Duration of the assumed role session in seconds. Must be between 900 and 43200. Defaults to 3600 when omitted.",
|
||||
),
|
||||
aws_access_key_id: str | None = Field(
|
||||
aws_access_key_id: NonBlankStr | None = Field(
|
||||
default=None, description="AWS access key ID for dedicated credentials."
|
||||
),
|
||||
aws_secret_access_key: str | None = Field(
|
||||
aws_secret_access_key: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="AWS secret access key. Required when 'aws_access_key_id' is provided.",
|
||||
),
|
||||
aws_session_token: str | None = Field(
|
||||
aws_session_token: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="AWS session token, only for temporary credentials.",
|
||||
),
|
||||
@@ -344,40 +343,36 @@ class IntegrationsTools(BaseTool):
|
||||
f"Creating AWS Security Hub integration for provider {provider_id}..."
|
||||
)
|
||||
|
||||
try:
|
||||
credentials = self._build_aws_credentials(
|
||||
role_arn=role_arn,
|
||||
external_id=external_id,
|
||||
role_session_name=role_session_name,
|
||||
session_duration=session_duration,
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
aws_session_token=aws_session_token,
|
||||
)
|
||||
credentials = self._build_aws_credentials(
|
||||
role_arn=role_arn,
|
||||
external_id=external_id,
|
||||
role_session_name=role_session_name,
|
||||
session_duration=session_duration,
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
aws_session_token=aws_session_token,
|
||||
)
|
||||
|
||||
return await self._create_integration(
|
||||
integration_type="aws_security_hub",
|
||||
configuration={
|
||||
"send_only_fails": send_only_fails,
|
||||
"archive_previous_findings": archive_previous_findings,
|
||||
},
|
||||
credentials=credentials,
|
||||
provider_ids=[provider_id],
|
||||
enabled=enabled,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"AWS Security Hub integration creation failed: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
return await self._create_integration(
|
||||
integration_type="aws_security_hub",
|
||||
configuration={
|
||||
"send_only_fails": send_only_fails,
|
||||
"archive_previous_findings": archive_previous_findings,
|
||||
},
|
||||
credentials=credentials,
|
||||
provider_ids=[provider_id],
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
async def create_jira_integration(
|
||||
self,
|
||||
domain: str = Field(
|
||||
domain: NonBlankStr = Field(
|
||||
description="Atlassian site name, without the '.atlassian.net' suffix. For the site 'https://acme.atlassian.net' the value is 'acme'. Full URLs are accepted and normalized automatically."
|
||||
),
|
||||
user_mail: str = Field(
|
||||
user_mail: NonBlankStr = Field(
|
||||
description="Email address of the Atlassian account that owns the API token."
|
||||
),
|
||||
api_token: str = Field(
|
||||
api_token: NonBlankStr = Field(
|
||||
description="Atlassian API token, created from the account settings. It needs the 'read:jira-user', 'read:jira-work' and 'write:jira-work' scopes."
|
||||
),
|
||||
enabled: bool = Field(
|
||||
@@ -416,31 +411,25 @@ class IntegrationsTools(BaseTool):
|
||||
3. Use prowler_get_jira_issue_types with that project key to pick an issue type
|
||||
4. Use prowler_send_findings_to_jira to create the work items
|
||||
"""
|
||||
try:
|
||||
normalized_domain = self._normalize_atlassian_domain(domain)
|
||||
self.logger.info(
|
||||
f"Creating Jira integration for domain {normalized_domain}..."
|
||||
)
|
||||
normalized_domain = self._normalize_atlassian_domain(domain)
|
||||
self.logger.info(f"Creating Jira integration for domain {normalized_domain}...")
|
||||
|
||||
return await self._create_integration(
|
||||
integration_type="jira",
|
||||
# Jira rejects any configuration in the payload, the API generates it
|
||||
configuration={},
|
||||
credentials={
|
||||
"domain": normalized_domain,
|
||||
"user_mail": user_mail,
|
||||
"api_token": api_token,
|
||||
},
|
||||
provider_ids=[],
|
||||
enabled=enabled,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Jira integration creation failed: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
return await self._create_integration(
|
||||
integration_type="jira",
|
||||
# Jira rejects any configuration in the payload, the API generates it
|
||||
configuration={},
|
||||
credentials={
|
||||
"domain": normalized_domain,
|
||||
"user_mail": user_mail,
|
||||
"api_token": api_token,
|
||||
},
|
||||
provider_ids=[],
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
async def update_integration(
|
||||
self,
|
||||
integration_id: str = Field(
|
||||
integration_id: NonBlankStr = Field(
|
||||
description="UUID of the integration to update. Use prowler_list_integrations to find it."
|
||||
),
|
||||
enabled: bool | None = Field(
|
||||
@@ -494,96 +483,86 @@ class IntegrationsTools(BaseTool):
|
||||
"""
|
||||
self.logger.info(f"Updating integration {integration_id}...")
|
||||
|
||||
try:
|
||||
current = DetailedIntegration.from_api_response(
|
||||
await self._get_integration_raw(integration_id)
|
||||
)
|
||||
integration_type = current.integration_type
|
||||
current = DetailedIntegration.from_api_response(
|
||||
await self._get_integration_raw(integration_id)
|
||||
)
|
||||
integration_type = current.integration_type
|
||||
|
||||
if provider_ids is not None:
|
||||
if integration_type == "jira":
|
||||
raise ValueError(
|
||||
"Jira integrations are tenant-wide and cannot be attached to providers."
|
||||
)
|
||||
if integration_type == "aws_security_hub" and len(provider_ids) != 1:
|
||||
raise ValueError(
|
||||
"AWS Security Hub integrations must stay attached to exactly one AWS "
|
||||
f"provider, got {len(provider_ids)}. Pass a single provider ID, or use "
|
||||
"prowler_delete_integration to stop sending findings to Security Hub."
|
||||
)
|
||||
|
||||
attributes: dict[str, Any] = {}
|
||||
if enabled is not None:
|
||||
attributes["enabled"] = enabled
|
||||
|
||||
if credentials is not None:
|
||||
attributes["credentials"] = self._validate_credentials(
|
||||
integration_type, self._as_dict(credentials, "credentials")
|
||||
if provider_ids is not None:
|
||||
if integration_type == "jira":
|
||||
raise InvalidArgument(
|
||||
"Jira integrations are tenant-wide and cannot be attached to providers."
|
||||
)
|
||||
if integration_type == "aws_security_hub" and len(provider_ids) != 1:
|
||||
raise InvalidArgument(
|
||||
"AWS Security Hub integrations must stay attached to exactly one AWS "
|
||||
f"provider, got {len(provider_ids)}. Pass a single provider ID, or use "
|
||||
"prowler_delete_integration to stop sending findings to Security Hub."
|
||||
)
|
||||
|
||||
if configuration is not None:
|
||||
if integration_type == "jira":
|
||||
raise ValueError(
|
||||
"Jira integrations do not accept a configuration: it is generated by Prowler. "
|
||||
"Update the credentials instead, or run prowler_test_integration_connection to "
|
||||
"refresh the available projects and issue types."
|
||||
)
|
||||
merged = dict(current.configuration)
|
||||
merged.update(self._as_dict(configuration, "configuration"))
|
||||
# Server-owned, the API repopulates it from the connection check
|
||||
merged.pop("regions", None)
|
||||
merged.pop("enabled_regions", None)
|
||||
attributes["configuration"] = merged
|
||||
attributes: dict[str, Any] = {}
|
||||
if enabled is not None:
|
||||
attributes["enabled"] = enabled
|
||||
|
||||
if not attributes and provider_ids is None:
|
||||
self.logger.info("No changes provided, returning the current state")
|
||||
return current.model_dump()
|
||||
if credentials is not None:
|
||||
attributes["credentials"] = self._validate_credentials(
|
||||
integration_type, self._as_dict(credentials, "credentials")
|
||||
)
|
||||
|
||||
update_body: dict[str, Any] = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": integration_id,
|
||||
"attributes": attributes,
|
||||
}
|
||||
if configuration is not None:
|
||||
if integration_type == "jira":
|
||||
raise InvalidArgument(
|
||||
"Jira integrations do not accept a configuration: it is generated by Prowler. "
|
||||
"Update the credentials instead, or run prowler_test_integration_connection to "
|
||||
"refresh the available projects and issue types."
|
||||
)
|
||||
merged = dict(current.configuration)
|
||||
merged.update(self._as_dict(configuration, "configuration"))
|
||||
# Server-owned, the API repopulates it from the connection check
|
||||
merged.pop("regions", None)
|
||||
merged.pop("enabled_regions", None)
|
||||
attributes["configuration"] = merged
|
||||
|
||||
if not attributes and provider_ids is None:
|
||||
self.logger.info("No changes provided, returning the current state")
|
||||
return current.model_dump()
|
||||
|
||||
update_body: dict[str, Any] = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": integration_id,
|
||||
"attributes": attributes,
|
||||
}
|
||||
if provider_ids is not None:
|
||||
update_body["data"]["relationships"] = _providers_relationship(
|
||||
provider_ids
|
||||
)
|
||||
}
|
||||
if provider_ids is not None:
|
||||
update_body["data"]["relationships"] = _providers_relationship(provider_ids)
|
||||
|
||||
await self.api_client.patch(
|
||||
f"/integrations/{integration_id}", json_data=update_body
|
||||
)
|
||||
await self.api_client.patch(
|
||||
f"/integrations/{integration_id}", json_data=update_body
|
||||
)
|
||||
|
||||
# A different provider means different effective credentials and different
|
||||
# discovered configuration, so the stored connection state is stale too
|
||||
providers_changed = provider_ids is not None and set(provider_ids) != set(
|
||||
current.provider_ids
|
||||
)
|
||||
recheck_connection = (
|
||||
credentials is not None
|
||||
or configuration is not None
|
||||
or providers_changed
|
||||
)
|
||||
connection_status = (
|
||||
await self._test_connection(integration_id)
|
||||
if recheck_connection
|
||||
else None
|
||||
)
|
||||
# A different provider means different effective credentials and different
|
||||
# discovered configuration, so the stored connection state is stale too
|
||||
providers_changed = provider_ids is not None and set(provider_ids) != set(
|
||||
current.provider_ids
|
||||
)
|
||||
recheck_connection = (
|
||||
credentials is not None or configuration is not None or providers_changed
|
||||
)
|
||||
connection_status = (
|
||||
await self._test_connection(integration_id) if recheck_connection else None
|
||||
)
|
||||
|
||||
updated = await self._get_integration_raw(integration_id)
|
||||
if connection_status is not None:
|
||||
return IntegrationConnectionStatus.create(
|
||||
updated, connection_status
|
||||
).model_dump()
|
||||
return DetailedIntegration.from_api_response(updated).model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Integration update failed: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
updated = await self._get_integration_raw(integration_id)
|
||||
if connection_status is not None:
|
||||
return IntegrationConnectionStatus.create(
|
||||
updated, connection_status
|
||||
).model_dump()
|
||||
return DetailedIntegration.from_api_response(updated).model_dump()
|
||||
|
||||
async def delete_integration(
|
||||
self,
|
||||
integration_id: str = Field(
|
||||
integration_id: NonBlankStr = Field(
|
||||
description="UUID of the integration to permanently remove. Use prowler_list_integrations to find it."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -606,22 +585,15 @@ class IntegrationsTools(BaseTool):
|
||||
"""
|
||||
self.logger.info(f"Deleting integration {integration_id}...")
|
||||
|
||||
try:
|
||||
await self.api_client.delete(f"/integrations/{integration_id}")
|
||||
return {
|
||||
"deleted": True,
|
||||
"message": f"Integration {integration_id} deleted successfully",
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Integration deletion failed: {e}")
|
||||
return {
|
||||
"deleted": False,
|
||||
"message": f"Integration {integration_id} deletion failed: {str(e)}",
|
||||
}
|
||||
await self.api_client.delete(f"/integrations/{integration_id}")
|
||||
# No `deleted` flag: an integration that was not deleted leaves this tool
|
||||
# as an error, so the flag could only ever be True and a reader branching
|
||||
# on it would be looking for a shape that does not exist.
|
||||
return {"message": f"Integration {integration_id} deleted successfully"}
|
||||
|
||||
async def test_integration_connection(
|
||||
self,
|
||||
integration_id: str = Field(
|
||||
integration_id: NonBlankStr = Field(
|
||||
description="UUID of the integration to check. Use prowler_list_integrations to find it."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -654,10 +626,10 @@ class IntegrationsTools(BaseTool):
|
||||
|
||||
async def get_jira_issue_types(
|
||||
self,
|
||||
integration_id: str = Field(
|
||||
integration_id: NonBlankStr = Field(
|
||||
description="UUID of the Jira integration. Use prowler_list_integrations with integration_type=['jira'] to find it."
|
||||
),
|
||||
project_key: str = Field(
|
||||
project_key: NonBlankStr = Field(
|
||||
description="Key of the Jira project to read the issue types from (e.g. 'PROJ'). It must be one of the keys in the 'projects' mapping of the integration configuration."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -692,13 +664,13 @@ class IntegrationsTools(BaseTool):
|
||||
|
||||
async def send_findings_to_jira(
|
||||
self,
|
||||
integration_id: str = Field(
|
||||
integration_id: NonBlankStr = Field(
|
||||
description="UUID of the Jira integration to send the findings through. It must be enabled."
|
||||
),
|
||||
project_key: str = Field(
|
||||
project_key: NonBlankStr = Field(
|
||||
description="Key of the Jira project the work items are created in (e.g. 'PROJ'). It must be one of the keys in the 'projects' mapping of the integration configuration."
|
||||
),
|
||||
issue_type: str = Field(
|
||||
issue_type: NonBlankStr = Field(
|
||||
description="Jira issue type for the created work items (e.g. 'Task', 'Bug', 'Story'). It must be one of the values returned by prowler_get_jira_issue_types for this project."
|
||||
),
|
||||
finding_ids: list[str] = Field(
|
||||
@@ -783,20 +755,26 @@ class IntegrationsTools(BaseTool):
|
||||
return self._jira_dispatch_unknown(
|
||||
task_id=None,
|
||||
error=(
|
||||
f"the request that starts the dispatch failed on the server: {e} "
|
||||
"the request that starts the dispatch failed on Prowler's side. "
|
||||
"It may have been queued anyway."
|
||||
),
|
||||
)
|
||||
|
||||
self.logger.error(f"Jira dispatch was rejected by Prowler: {e}")
|
||||
return self._jira_dispatch_rejected(str(e))
|
||||
except CredentialError:
|
||||
# Authentication happens before the request goes out, so nothing was
|
||||
# queued. It is raised rather than reported as a dispatch outcome:
|
||||
# there is no partial state to describe, and the shared classifier
|
||||
# says what has to be fixed, which no retry of this call can.
|
||||
raise
|
||||
except Exception as e:
|
||||
# No answer came back, so the request may still have been accepted
|
||||
self.logger.error(f"Jira dispatch could not be started: {e}")
|
||||
return self._jira_dispatch_unknown(
|
||||
task_id=None,
|
||||
error=(
|
||||
f"the request that starts the dispatch got no answer: {e} "
|
||||
"the request that starts the dispatch got no answer. "
|
||||
"It may have been accepted anyway."
|
||||
),
|
||||
)
|
||||
@@ -866,7 +844,7 @@ class IntegrationsTools(BaseTool):
|
||||
normalized = normalized.removesuffix(".atlassian.net")
|
||||
|
||||
if not normalized:
|
||||
raise ValueError(
|
||||
raise InvalidArgument(
|
||||
f"Invalid Jira domain: {domain}. Provide the Atlassian site name, for example "
|
||||
"'acme' for the site 'https://acme.atlassian.net'."
|
||||
)
|
||||
@@ -890,7 +868,7 @@ class IntegrationsTools(BaseTool):
|
||||
if not isinstance(credentials.get(key), str) or not credentials[key].strip()
|
||||
]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
raise InvalidArgument(
|
||||
"Jira credentials are replaced as a whole, so 'domain', 'user_mail' and "
|
||||
f"'api_token' are all required. Missing or empty: {', '.join(missing)}. "
|
||||
"Sending an incomplete object would destroy the stored credentials and break "
|
||||
@@ -908,29 +886,33 @@ class IntegrationsTools(BaseTool):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON for {param_name}: {e}")
|
||||
raise InvalidArgument(f"Invalid JSON for {param_name}: {e}") from e
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{param_name} must be a JSON object.")
|
||||
raise InvalidArgument(f"{param_name} must be a JSON object.")
|
||||
return value
|
||||
|
||||
async def _get_integration_raw(self, integration_id: str) -> dict[str, Any]:
|
||||
"""Fetch the raw JSON:API resource of an integration.
|
||||
|
||||
Raises:
|
||||
ValueError: If the payload does not contain a usable integration resource
|
||||
ToolError: If the payload does not contain a usable integration resource.
|
||||
Raised without a ``from`` clause because these messages name the
|
||||
integration and the tool that lists valid IDs, and the two cases
|
||||
are reported differently: a missing resource is the caller's
|
||||
mistake, a resource without attributes is the API's.
|
||||
"""
|
||||
response = await self.api_client.get(f"/integrations/{integration_id}")
|
||||
integration = response.get("data")
|
||||
|
||||
if not isinstance(integration, dict) or not integration.get("id"):
|
||||
raise ValueError(
|
||||
raise ToolError(
|
||||
f"Integration {integration_id} was not found. Use prowler_list_integrations "
|
||||
"to get a valid integration ID."
|
||||
)
|
||||
|
||||
if not isinstance(integration.get("attributes"), dict):
|
||||
raise ValueError(
|
||||
raise ToolError(
|
||||
f"Prowler returned integration {integration_id} without its attributes, so "
|
||||
"its state cannot be read."
|
||||
)
|
||||
@@ -970,7 +952,9 @@ class IntegrationsTools(BaseTool):
|
||||
integration_id = api_response.get("data", {}).get("id")
|
||||
|
||||
if not integration_id:
|
||||
raise ValueError(
|
||||
# The integration may well exist, so this must not read as "nothing
|
||||
# happened" and invite a duplicate.
|
||||
raise ToolError(
|
||||
"Prowler accepted the integration creation but did not return its ID, so the "
|
||||
"connection could not be checked. Use prowler_list_integrations to see whether "
|
||||
"the integration exists before creating it again."
|
||||
@@ -981,11 +965,17 @@ class IntegrationsTools(BaseTool):
|
||||
try:
|
||||
integration = await self._get_integration_raw(integration_id)
|
||||
except Exception as e:
|
||||
# The integration exists, so surface its ID instead of a plain read failure
|
||||
raise ValueError(
|
||||
f"Integration {integration_id} was created, but reading its state failed: {e} "
|
||||
# The integration exists, so surface its ID instead of a plain read
|
||||
# failure. No `from` clause: a cause would let the shared classifier
|
||||
# replace this with a sentence that does not mention the ID. The
|
||||
# failure text stays in the log, where the classifier would keep it.
|
||||
self.logger.error(
|
||||
f"Integration {integration_id} could not be read back: {e}"
|
||||
)
|
||||
raise ToolError(
|
||||
f"Integration {integration_id} was created, but reading its state failed. "
|
||||
"Use prowler_get_integration with that ID to check it."
|
||||
) from e
|
||||
)
|
||||
|
||||
return IntegrationConnectionStatus.create(
|
||||
integration, connection_status
|
||||
@@ -1030,7 +1020,7 @@ class IntegrationsTools(BaseTool):
|
||||
return {
|
||||
"connected": None,
|
||||
"error": (
|
||||
f"The connection check could not be completed: {e} This says nothing "
|
||||
"The connection check could not be completed. This says nothing "
|
||||
"about the stored credentials, run prowler_test_integration_connection "
|
||||
"to check them again."
|
||||
),
|
||||
|
||||
@@ -8,8 +8,11 @@ This module provides tools for managing finding muting in Prowler, including:
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.errors import InvalidArgument
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.muting import (
|
||||
DetailedMuteRule,
|
||||
MutelistResponse,
|
||||
@@ -28,10 +31,31 @@ class MutingTools(BaseTool):
|
||||
|
||||
# ===== MUTELIST TOOLS =====
|
||||
|
||||
async def _get_mutelist_raw(self) -> dict[str, Any] | None:
|
||||
"""Return the tenant's mutelist, or None when it has none.
|
||||
|
||||
Returns:
|
||||
The mutelist configuration, or None when the tenant has none
|
||||
"""
|
||||
params = {
|
||||
"filter[processor_type]": "mutelist",
|
||||
"fields[processors]": "processor_type,configuration,inserted_at,updated_at",
|
||||
}
|
||||
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
api_response = await self.api_client.get("/processors", params=clean_params)
|
||||
|
||||
data = api_response.get("data", [])
|
||||
if not data:
|
||||
return None
|
||||
|
||||
# Only one mutelist can exist per tenant
|
||||
return MutelistResponse.from_api_response(data[0]).model_dump()
|
||||
|
||||
async def get_mutelist(self) -> dict[str, Any]:
|
||||
"""Retrieve the current mutelist configuration for the tenant.
|
||||
|
||||
IMPORTANT: Only one mutelist can exist per tenant. Returns an error message if no mutelist exists.
|
||||
IMPORTANT: Only one mutelist can exist per tenant. Fails with a message saying so if no mutelist exists.
|
||||
For detailed information about mutelist structure and configuration, search Prowler documentation
|
||||
using prowler_docs_search tool available in this MCP Server.
|
||||
|
||||
@@ -47,26 +71,15 @@ class MutingTools(BaseTool):
|
||||
"""
|
||||
self.logger.info("Retrieving mutelist configuration...")
|
||||
|
||||
# Query processors filtered by type=mutelist
|
||||
params = {
|
||||
"filter[processor_type]": "mutelist",
|
||||
"fields[processors]": "processor_type,configuration,inserted_at,updated_at",
|
||||
}
|
||||
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
api_response = await self.api_client.get("/processors", params=clean_params)
|
||||
|
||||
data = api_response.get("data", [])
|
||||
|
||||
if len(data) == 0:
|
||||
return {
|
||||
"error": "No mutelist found",
|
||||
"message": "No mutelist configuration exists for this tenant. Use prowler_set_mutelist to create one.",
|
||||
}
|
||||
|
||||
# Return the first (and only) mutelist
|
||||
mutelist = MutelistResponse.from_api_response(data[0])
|
||||
return mutelist.model_dump()
|
||||
mutelist = await self._get_mutelist_raw()
|
||||
if mutelist is None:
|
||||
# No `from`: this names the tool that creates one, which the shared
|
||||
# classifier cannot know.
|
||||
raise ToolError(
|
||||
"No mutelist configuration exists for this tenant. Use "
|
||||
"prowler_set_mutelist to create one."
|
||||
)
|
||||
return mutelist
|
||||
|
||||
async def set_mutelist(
|
||||
self,
|
||||
@@ -128,9 +141,9 @@ Structure:
|
||||
configuration = json.loads(configuration)
|
||||
|
||||
# Check if mutelist already exists
|
||||
existing_mutelist = await self.get_mutelist()
|
||||
existing_mutelist = await self._get_mutelist_raw()
|
||||
|
||||
if "error" in existing_mutelist:
|
||||
if existing_mutelist is None:
|
||||
# Create new mutelist
|
||||
self.logger.info("Creating new mutelist...")
|
||||
create_body = {
|
||||
@@ -183,21 +196,22 @@ Structure:
|
||||
self.logger.info("Deleting mutelist configuration...")
|
||||
|
||||
# Get existing mutelist
|
||||
existing_mutelist = await self.get_mutelist()
|
||||
existing_mutelist = await self._get_mutelist_raw()
|
||||
|
||||
if "error" in existing_mutelist:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No mutelist found to delete",
|
||||
}
|
||||
if existing_mutelist is None:
|
||||
raise ToolError(
|
||||
"There is no mutelist configuration to delete. Use "
|
||||
"prowler_get_mutelist to confirm the current state."
|
||||
)
|
||||
|
||||
# Delete the mutelist
|
||||
mutelist_id = existing_mutelist["id"]
|
||||
await self.api_client.delete(f"/processors/{mutelist_id}")
|
||||
|
||||
# No success flag: a deletion that did not happen leaves this tool as an
|
||||
# error, so there is no second shape for one to distinguish.
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Mutelist deleted successfully",
|
||||
"message": "Mutelist deleted successfully. Findings it had muted stay muted."
|
||||
}
|
||||
|
||||
# ===== MUTE RULES TOOLS =====
|
||||
@@ -268,7 +282,7 @@ Structure:
|
||||
elif enabled.lower() == "false":
|
||||
params["filter[enabled]"] = False
|
||||
else:
|
||||
raise ValueError(
|
||||
raise InvalidArgument(
|
||||
f"Invalid enabled value: {enabled}. Valid values are True, False, 'true', 'false' or None."
|
||||
)
|
||||
if search:
|
||||
@@ -282,7 +296,7 @@ Structure:
|
||||
|
||||
async def get_mute_rule(
|
||||
self,
|
||||
rule_id: str = Field(
|
||||
rule_id: NonBlankStr = Field(
|
||||
description="UUID of the mute rule to retrieve. Must be a valid UUID format (e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac')."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -316,10 +330,10 @@ Structure:
|
||||
|
||||
async def create_mute_rule(
|
||||
self,
|
||||
name: str = Field(
|
||||
name: NonBlankStr = Field(
|
||||
description="Name for the mute rule. Should be descriptive and meaningful (e.g., 'Dev S3 Public Access', 'Test Environment IMDSv1')."
|
||||
),
|
||||
reason: str = Field(
|
||||
reason: NonBlankStr = Field(
|
||||
description="Reason for muting these findings. Document why this security issue is acceptable or intentional (e.g., 'Development environment with controlled access', 'Legacy application requires IMDSv1')."
|
||||
),
|
||||
finding_ids: list[str] = Field(
|
||||
@@ -367,14 +381,14 @@ Structure:
|
||||
|
||||
async def update_mute_rule(
|
||||
self,
|
||||
rule_id: str = Field(
|
||||
rule_id: NonBlankStr = Field(
|
||||
description="UUID of the mute rule to update. Must be a valid UUID format."
|
||||
),
|
||||
name: str | None = Field(
|
||||
name: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="New name for the rule. If not specified, name remains unchanged.",
|
||||
),
|
||||
reason: str | None = Field(
|
||||
reason: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="New reason for the rule. If not specified, reason remains unchanged.",
|
||||
),
|
||||
@@ -435,7 +449,7 @@ Structure:
|
||||
|
||||
async def delete_mute_rule(
|
||||
self,
|
||||
rule_id: str = Field(
|
||||
rule_id: NonBlankStr = Field(
|
||||
description="UUID of the mute rule to delete. Must be a valid UUID format."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -457,15 +471,18 @@ Structure:
|
||||
"""
|
||||
self.logger.info(f"Deleting mute rule {rule_id}...")
|
||||
|
||||
result = await self.api_client.delete(f"/mute-rules/{rule_id}")
|
||||
# A deletion that did not happen answers with an error status, which
|
||||
# leaves this tool as an error. Reaching this line means Prowler accepted
|
||||
# it, whether it answered 204 with no body or 200 with the deleted
|
||||
# resource, so there is no second outcome to report: the previous
|
||||
# "Failed to delete mute rule" fired on the shape of the answer rather
|
||||
# than on anything having gone wrong, and said nothing a caller could act
|
||||
# on.
|
||||
await self.api_client.delete(f"/mute-rules/{rule_id}")
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Mute rule deleted successfully",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to delete mute rule",
|
||||
}
|
||||
return {
|
||||
"message": (
|
||||
f"Mute rule {rule_id} deleted successfully. The findings it muted stay "
|
||||
"muted."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ including searching, connecting, and deleting providers.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.errors import InvalidArgument
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.providers import (
|
||||
ProviderConnectionStatus,
|
||||
ProviderDeletionResult,
|
||||
ProvidersListResponse,
|
||||
)
|
||||
from prowler_mcp_server.prowler_app.tools.base import BaseTool
|
||||
@@ -95,7 +99,7 @@ class ProvidersTools(BaseTool):
|
||||
elif connected.lower() == "false":
|
||||
params["filter[connected]"] = False
|
||||
else:
|
||||
raise ValueError(
|
||||
raise InvalidArgument(
|
||||
f"Invalid connected value: {connected}. Valid values are True, False, 'true', 'false' or None."
|
||||
)
|
||||
|
||||
@@ -128,13 +132,13 @@ class ProvidersTools(BaseTool):
|
||||
|
||||
async def connect_provider(
|
||||
self,
|
||||
provider_uid: str = Field(
|
||||
provider_uid: NonBlankStr = Field(
|
||||
description="Provider's unique identifier. For supported UID provider formats, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server"
|
||||
),
|
||||
provider_type: str = Field(
|
||||
provider_type: NonBlankStr = Field(
|
||||
description="Type of provider to be scanned with Prowler. Valid values include: 'aws', 'azure', 'gcp', 'kubernetes'... For more valid values, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server."
|
||||
),
|
||||
alias: str | None = Field(
|
||||
alias: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="Human-friendly name for this provider. Optional but recommended for easy identification. Use descriptive names to distinguish multiple accounts of the same type.",
|
||||
),
|
||||
@@ -291,7 +295,7 @@ class ProvidersTools(BaseTool):
|
||||
|
||||
async def delete_provider(
|
||||
self,
|
||||
provider_id: str = Field(
|
||||
provider_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the provider to permanently remove, generated when the provider was registered in the system. Use `prowler_search_providers` tool to find the provider_id if you only know the alias or the provider's own identifier (provider_uid)"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -300,33 +304,120 @@ class ProvidersTools(BaseTool):
|
||||
WARNING: This is a destructive operation that cannot be undone. The provider will need to be
|
||||
re-added with prowler_connect_provider if you want to scan it again.
|
||||
|
||||
The tool always returns the deletion status and message.
|
||||
Prowler removes the provider and everything attached to it (its scans, findings and
|
||||
resources) in a background task, so a large provider can take longer than the time
|
||||
this tool waits for it.
|
||||
|
||||
The result includes:
|
||||
- status: 'deleted' when Prowler finished removing the provider, 'in_progress' when
|
||||
the deletion was accepted and is still running
|
||||
- task_id: the background task, present when the deletion was still running
|
||||
|
||||
NEVER send the deletion again while status='in_progress'. Use prowler_search_providers
|
||||
to check whether the provider is gone.
|
||||
"""
|
||||
self.logger.info(f"Deleting provider {provider_id}...")
|
||||
try:
|
||||
# Initiate the deletion task
|
||||
task_response = await self.api_client.delete(f"/providers/{provider_id}")
|
||||
task_id = task_response.get("data", {}).get("id")
|
||||
|
||||
# Poll until task completes (with 60 second timeout)
|
||||
# A failure of the request itself is left to the shared classifier: the
|
||||
# deletion never started, so there is no partial state to describe.
|
||||
task_response = await self.api_client.delete(f"/providers/{provider_id}")
|
||||
task_id = task_response.get("data", {}).get("id")
|
||||
|
||||
if not task_id:
|
||||
# The deletion may well be running, so this must not read as "nothing
|
||||
# happened". No `from` clause: this names the provider and the tool
|
||||
# that checks it, neither of which the shared classifier can know.
|
||||
raise ToolError(
|
||||
f"Prowler accepted the deletion of provider {provider_id} but did not "
|
||||
"return the ID of the background task, so its outcome cannot be checked. "
|
||||
"Use prowler_search_providers to see whether the provider is still there "
|
||||
"before sending the deletion again."
|
||||
)
|
||||
|
||||
try:
|
||||
await self.api_client.poll_task_until_complete(
|
||||
task_id=task_id, timeout=60, poll_interval=1.0
|
||||
)
|
||||
|
||||
# If we reach here, the task completed successfully
|
||||
return {
|
||||
"deleted": True,
|
||||
"message": f"Provider {provider_id} deleted successfully",
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Provider deletion failed: {e}")
|
||||
return {
|
||||
"deleted": False,
|
||||
"message": f"Provider {provider_id} deletion failed: {str(e)}",
|
||||
}
|
||||
self.logger.error(f"Provider deletion did not complete cleanly: {e}")
|
||||
return await self._provider_deletion_fallback(provider_id, task_id)
|
||||
|
||||
return ProviderDeletionResult(
|
||||
status="deleted",
|
||||
message=f"Provider {provider_id} deleted successfully",
|
||||
).model_dump()
|
||||
|
||||
# Private helper methods
|
||||
|
||||
async def _provider_deletion_fallback(
|
||||
self, provider_id: str, task_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Report a provider deletion whose polling did not end on a completed task.
|
||||
|
||||
Running out of the polling window is not a failure: Prowler removes the
|
||||
provider together with its scans, findings and resources, which outlives
|
||||
60 seconds on a large account. The deletion was accepted and is still
|
||||
going, so calling it failed would be wrong twice over -- it is not, and
|
||||
it invites a retry of a destructive call already in flight.
|
||||
|
||||
The task is read once more here, because polling gives up on the clock
|
||||
rather than on the task: a deletion that finished just after the last
|
||||
poll is a finished deletion and is reported as one.
|
||||
|
||||
Only a task that actually stopped is an error, and it is raised rather
|
||||
than returned, because then the provider is still there.
|
||||
|
||||
Raises:
|
||||
ToolError: If the deletion task ended without deleting the provider.
|
||||
Raised without a ``from`` clause because the message names what
|
||||
was left behind, which the shared classifier cannot know.
|
||||
"""
|
||||
state = None
|
||||
try:
|
||||
task = await self.api_client.get(f"/tasks/{task_id}")
|
||||
state = task.get("data", {}).get("attributes", {}).get("state")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Could not read the state of task {task_id}: {e}")
|
||||
|
||||
if state == "completed":
|
||||
# The deletion outran the polling window by a moment, not by more.
|
||||
return ProviderDeletionResult(
|
||||
status="deleted",
|
||||
message=f"Provider {provider_id} deleted successfully",
|
||||
).model_dump()
|
||||
|
||||
if state in ("failed", "cancelled"):
|
||||
# The failure that got us here is logged, not relayed: it carries
|
||||
# upstream text, and the classifier masks exactly this kind of
|
||||
# message when a tool does not write it itself.
|
||||
raise ToolError(
|
||||
f"The task deleting provider {provider_id} ended as '{state}', so the "
|
||||
"provider was not deleted. Prowler removes a provider together with its "
|
||||
"scans, findings and resources, so part of that may already be gone. Use "
|
||||
"prowler_search_providers to check the current state."
|
||||
)
|
||||
|
||||
if state is None:
|
||||
message = (
|
||||
f"The deletion of provider {provider_id} was accepted, but its progress "
|
||||
"could not be read, so whether it finished is unknown. Do not "
|
||||
"send the deletion again. Use prowler_search_providers to check whether "
|
||||
"the provider is gone."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"The deletion of provider {provider_id} was accepted and is still "
|
||||
f"running (task state '{state}'), which is normal for a provider with "
|
||||
"many scans and findings. Do not send the deletion again. Use "
|
||||
"prowler_search_providers to check whether it is gone."
|
||||
)
|
||||
|
||||
return ProviderDeletionResult(
|
||||
status="in_progress",
|
||||
task_id=task_id,
|
||||
message=message,
|
||||
).model_dump()
|
||||
|
||||
async def _check_provider_exists(self, provider_uid: str) -> str | None:
|
||||
"""Check if a provider already exists by its UID.
|
||||
|
||||
@@ -357,7 +448,7 @@ class ProvidersTools(BaseTool):
|
||||
return prowler_provider_id
|
||||
else:
|
||||
# Multiple providers with the same UID is a data integrity issue
|
||||
raise Exception(
|
||||
raise ToolError(
|
||||
f"Data integrity error: Found {len(providers)} providers with UID '{provider_uid}'. "
|
||||
f"Each provider UID should be unique. Please contact support or manually clean up duplicate providers."
|
||||
)
|
||||
@@ -392,7 +483,11 @@ class ProvidersTools(BaseTool):
|
||||
|
||||
provider_id = await self._check_provider_exists(provider_uid)
|
||||
if provider_id is None:
|
||||
raise Exception(f"Provider {provider_uid} creation failed")
|
||||
raise ToolError(
|
||||
f"Prowler accepted the creation of provider {provider_uid} but the "
|
||||
"provider cannot be found afterwards. Use prowler_search_providers to "
|
||||
"check whether it exists before creating it again."
|
||||
)
|
||||
return provider_id
|
||||
|
||||
async def _update_provider_alias(
|
||||
@@ -418,7 +513,10 @@ class ProvidersTools(BaseTool):
|
||||
f"/providers/{prowler_provider_id}", json_data=update_body
|
||||
)
|
||||
if result.get("data", {}).get("attributes", {}).get("alias") != alias:
|
||||
raise Exception(f"Provider {prowler_provider_id} alias update failed")
|
||||
raise ToolError(
|
||||
f"Provider {prowler_provider_id} exists, but its alias was not updated. "
|
||||
"Use prowler_search_providers to read its current alias."
|
||||
)
|
||||
|
||||
def _determine_secret_type(self, credentials: dict[str, Any]) -> str:
|
||||
"""Determine the secret type from credentials structure.
|
||||
@@ -443,29 +541,32 @@ class ProvidersTools(BaseTool):
|
||||
prowler_provider_id: The Prowler-generated provider ID
|
||||
|
||||
Returns:
|
||||
The secret ID if exists, None otherwise
|
||||
"""
|
||||
try:
|
||||
response = await self.api_client.get(
|
||||
"/providers/secrets",
|
||||
params={"filter[provider]": prowler_provider_id},
|
||||
)
|
||||
secrets = response.get("data", [])
|
||||
The secret ID if the provider has one, None if it has none
|
||||
|
||||
if len(secrets) > 0:
|
||||
secret_id = secrets[0].get("id")
|
||||
self.logger.info(
|
||||
f"Found existing secret {secret_id} for provider {prowler_provider_id}"
|
||||
)
|
||||
return secret_id
|
||||
else:
|
||||
self.logger.info(
|
||||
f"No existing secret found for provider {prowler_provider_id}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking for existing secret: {e}")
|
||||
return None
|
||||
Raises:
|
||||
Exception: If the lookup itself failed, so that "no secret" is never
|
||||
reported for a provider whose secret could not be read
|
||||
"""
|
||||
# A failure here is not swallowed into None. None means "this provider has
|
||||
# no secret", which sends `_store_credentials` down the create branch, and
|
||||
# a provider holds at most one secret: creating a second one is refused,
|
||||
# and the caller would be told its credentials were rejected when all that
|
||||
# actually failed was this read.
|
||||
response = await self.api_client.get(
|
||||
"/providers/secrets",
|
||||
params={"filter[provider]": prowler_provider_id},
|
||||
)
|
||||
secrets = response.get("data", [])
|
||||
|
||||
if len(secrets) > 0:
|
||||
secret_id = secrets[0].get("id")
|
||||
self.logger.info(
|
||||
f"Found existing secret {secret_id} for provider {prowler_provider_id}"
|
||||
)
|
||||
return secret_id
|
||||
|
||||
self.logger.info(f"No existing secret found for provider {prowler_provider_id}")
|
||||
return None
|
||||
|
||||
async def _get_secret_type(self, secret_id: str) -> str | None:
|
||||
"""Get the secret type for a given secret ID.
|
||||
@@ -573,13 +674,24 @@ class ProvidersTools(BaseTool):
|
||||
raise
|
||||
|
||||
async def _test_connection(self, prowler_provider_id: str) -> dict[str, Any]:
|
||||
"""Test connection to a provider.
|
||||
"""Test connection to a provider and wait for the result.
|
||||
|
||||
A test that could not be run is reported as 'connected: None', which
|
||||
`ProviderConnectionStatus` renders as 'not_tested', rather than as a
|
||||
failure. Credentials that do not work come back as a completed task
|
||||
carrying 'connected: False', so an exception here never describes them:
|
||||
it means this server could not get the test run at all -- an expired
|
||||
Prowler credential, a rate limit, a test that outlived the timeout.
|
||||
Reporting that as 'failed' would blame the provider's credentials for
|
||||
something they did not cause, and send the caller off to fix a working
|
||||
role.
|
||||
|
||||
Args:
|
||||
prowler_provider_id: The Prowler-generated provider ID
|
||||
|
||||
Returns:
|
||||
Connection status dictionary with 'connected' boolean and optional 'error' message
|
||||
Connection status dictionary with a 'connected' boolean or None, and
|
||||
an optional 'error' message
|
||||
"""
|
||||
self.logger.info(f"Testing connection for provider {prowler_provider_id}...")
|
||||
try:
|
||||
@@ -589,6 +701,11 @@ class ProvidersTools(BaseTool):
|
||||
)
|
||||
task_id = task_response.get("data", {}).get("id")
|
||||
|
||||
if not task_id:
|
||||
raise ValueError(
|
||||
"Prowler did not return the ID of the connection test task."
|
||||
)
|
||||
|
||||
# Poll until task completes (with 60 second timeout)
|
||||
completed_task = await self.api_client.poll_task_until_complete(
|
||||
task_id=task_id, timeout=60, poll_interval=1.0
|
||||
@@ -596,13 +713,26 @@ class ProvidersTools(BaseTool):
|
||||
|
||||
# Extract the result from the completed task
|
||||
task_result = (
|
||||
completed_task.get("data", {}).get("attributes", {}).get("result", {})
|
||||
completed_task.get("data", {}).get("attributes", {}).get("result")
|
||||
)
|
||||
|
||||
if not isinstance(task_result, dict):
|
||||
raise ValueError(
|
||||
"The connection test task completed without reporting a result."
|
||||
)
|
||||
|
||||
return task_result
|
||||
except Exception as e:
|
||||
self.logger.error(f"Connection test failed: {e}")
|
||||
return {"connected": False, "error": str(e)}
|
||||
self.logger.error(f"Connection test could not be completed: {e}")
|
||||
return {
|
||||
"connected": None,
|
||||
"error": (
|
||||
"The connection test could not be completed. This says nothing "
|
||||
"about the provider's credentials, they were never tested. Use "
|
||||
"prowler_search_providers to read the connection state Prowler has "
|
||||
"stored for this provider."
|
||||
),
|
||||
}
|
||||
|
||||
async def _get_final_provider_state(
|
||||
self, prowler_provider_id: str
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.resources import (
|
||||
DetailedResource,
|
||||
ResourceEventsResponse,
|
||||
@@ -176,7 +177,7 @@ class ResourcesTools(BaseTool):
|
||||
|
||||
async def get_resource(
|
||||
self,
|
||||
resource_id: str = Field(
|
||||
resource_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the resource to retrieve, generated when the resource was discovered in the system. Use `prowler_list_resources` tool to find the right ID"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -347,7 +348,7 @@ class ResourcesTools(BaseTool):
|
||||
|
||||
async def get_resource_events(
|
||||
self,
|
||||
resource_id: str = Field(
|
||||
resource_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the resource. Use `prowler_list_resources` to find the right ID, or get it from a finding's resource relationship via `prowler_get_finding_details`."
|
||||
),
|
||||
lookback_days: int = Field(
|
||||
|
||||
@@ -11,8 +11,11 @@ adding to it.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.errors import ProwlerAPIError
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.roles import (
|
||||
DetailedRole,
|
||||
RolesListResponse,
|
||||
@@ -70,7 +73,7 @@ class RolesTools(BaseTool):
|
||||
|
||||
async def get_role(
|
||||
self,
|
||||
role_id: str = Field(
|
||||
role_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the role to retrieve. Use `prowler_list_roles` to find role IDs if you only know a name."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -98,7 +101,7 @@ class RolesTools(BaseTool):
|
||||
|
||||
async def get_user_roles(
|
||||
self,
|
||||
user_id: str = Field(
|
||||
user_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the user whose roles you want. Use `prowler_list_users` to find user IDs, or `prowler_get_current_user` for the caller."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -124,10 +127,10 @@ class RolesTools(BaseTool):
|
||||
|
||||
async def set_user_role(
|
||||
self,
|
||||
user_id: str = Field(
|
||||
user_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the user whose role you want to set. Use `prowler_list_users` to find user IDs."
|
||||
),
|
||||
role_id: str = Field(
|
||||
role_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the role the user should hold. Use `prowler_list_roles` to find role IDs."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -166,11 +169,20 @@ class RolesTools(BaseTool):
|
||||
# user with no role at all. Confirm the role exists before replacing.
|
||||
try:
|
||||
await self.api_client.get(f"/roles/{role_id}")
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Role {role_id} could not be read ({e}), so user {user_id} was left "
|
||||
f"unchanged. Use `prowler_list_roles` to find a valid role ID."
|
||||
) from e
|
||||
except ProwlerAPIError as e:
|
||||
if e.status_code != 404:
|
||||
# Only a not-found says anything about the role ID. A permission
|
||||
# error, a rate limit or a server error is about the request, so
|
||||
# it goes to the shared classifier rather than being reported as
|
||||
# an ID the caller should replace.
|
||||
raise
|
||||
# No `from` clause: this says what state the user was left in, which
|
||||
# the shared classifier cannot know, and a cause would let it replace
|
||||
# this message with its own.
|
||||
raise ToolError(
|
||||
f"Role {role_id} does not exist in this tenant, so user {user_id} was "
|
||||
f"left unchanged. Use `prowler_list_roles` to find a valid role ID."
|
||||
)
|
||||
|
||||
# PATCH replaces the user's whole role set with this single role, the
|
||||
# same call the Prowler UI makes when changing a user's role.
|
||||
|
||||
@@ -5,8 +5,10 @@ This module provides tools for managing and monitoring Prowler security scans.
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.scans import (
|
||||
DetailedScan,
|
||||
ScanCreationResult,
|
||||
@@ -127,7 +129,7 @@ class ScansTools(BaseTool):
|
||||
|
||||
async def get_scan(
|
||||
self,
|
||||
scan_id: str = Field(
|
||||
scan_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the scan to retrieve, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find scan IDs"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -171,10 +173,10 @@ class ScansTools(BaseTool):
|
||||
|
||||
async def trigger_scan(
|
||||
self,
|
||||
provider_id: str = Field(
|
||||
provider_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID"
|
||||
),
|
||||
name: str | None = Field(
|
||||
name: NonBlankStr | None = Field(
|
||||
default=None,
|
||||
description="Optional human-friendly name for the scan. Use descriptive names to identify scan purpose or context, e.g., 'Weekly Production Security Audit', 'Pre-Deployment Validation', 'Compliance Check Q4 2025'",
|
||||
),
|
||||
@@ -191,60 +193,70 @@ class ScansTools(BaseTool):
|
||||
3. Use `prowler_get_scan` with the returned scan 'id' to monitor progress
|
||||
4. Once completed, use `prowler_search_security_findings` to analyze results
|
||||
"""
|
||||
try:
|
||||
# Build request data
|
||||
request_data: dict[str, Any] = {
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"attributes": {},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": provider_id,
|
||||
},
|
||||
# Build request data
|
||||
request_data: dict[str, Any] = {
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"attributes": {},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": provider_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if name:
|
||||
request_data["data"]["attributes"]["name"] = name
|
||||
},
|
||||
}
|
||||
if name:
|
||||
request_data["data"]["attributes"]["name"] = name
|
||||
|
||||
# Create scan (returns Task)
|
||||
self.logger.info(f"Creating scan for provider {provider_id}")
|
||||
task_response = await self.api_client.post("/scans", json_data=request_data)
|
||||
# Create scan (returns Task)
|
||||
self.logger.info(f"Creating scan for provider {provider_id}")
|
||||
task_response = await self.api_client.post("/scans", json_data=request_data)
|
||||
|
||||
scan_id = (
|
||||
task_response.get("data", {})
|
||||
.get("attributes", {})
|
||||
.get("task_args", {})
|
||||
.get("scan_id", None)
|
||||
scan_id = (
|
||||
task_response.get("data", {})
|
||||
.get("attributes", {})
|
||||
.get("task_args", {})
|
||||
.get("scan_id", None)
|
||||
)
|
||||
|
||||
if not scan_id:
|
||||
# The scan may well have been queued, so this must not read as
|
||||
# "nothing happened" and invite a duplicate run. No `from` clause:
|
||||
# this names the provider and the tool that checks for the scan,
|
||||
# neither of which the shared classifier can know.
|
||||
raise ToolError(
|
||||
"Prowler accepted the scan but did not return its ID, so it "
|
||||
"cannot be looked up. Use prowler_list_scans for provider "
|
||||
f"{provider_id} to see whether a scan is already running before "
|
||||
"triggering another one."
|
||||
)
|
||||
|
||||
if not scan_id:
|
||||
raise Exception("No scan_id returned from scan creation")
|
||||
|
||||
self.logger.info(f"Scan created successfully: {scan_id}")
|
||||
# The scan exists from here on, so a failure to read it back must name
|
||||
# the ID rather than read as "the scan was not created".
|
||||
try:
|
||||
scan_response = await self.api_client.get(f"/scans/{scan_id}")
|
||||
scan_info = DetailedScan.from_api_response(scan_response["data"])
|
||||
|
||||
return ScanCreationResult(
|
||||
scan=scan_info,
|
||||
status="success",
|
||||
message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_get_scan tool with this ID to monitor progress.",
|
||||
).model_dump()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Scan creation failed: {e}")
|
||||
return ScanCreationResult(
|
||||
scan=None,
|
||||
status="failed",
|
||||
message=f"Scan creation failed: {str(e)}",
|
||||
).model_dump()
|
||||
# The failure itself is logged, not relayed: what it says is the
|
||||
# shared classifier's to mask, and what the caller needs is the ID.
|
||||
self.logger.error(f"Scan {scan_id} could not be read back: {e}")
|
||||
raise ToolError(
|
||||
f"Scan {scan_id} was created for provider {provider_id}, but reading "
|
||||
"its state failed. Use prowler_get_scan with that ID to monitor "
|
||||
"it. Do not trigger the scan again."
|
||||
)
|
||||
|
||||
return ScanCreationResult(
|
||||
scan=scan_info,
|
||||
message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_get_scan tool with this ID to monitor progress.",
|
||||
).model_dump()
|
||||
|
||||
async def schedule_daily_scan(
|
||||
self,
|
||||
provider_id: str = Field(
|
||||
provider_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -280,26 +292,49 @@ class ScansTools(BaseTool):
|
||||
},
|
||||
},
|
||||
)
|
||||
task_state = (
|
||||
|
||||
# Reaching this line means the schedule exists. Prowler commits the
|
||||
# recurring schedule and its first scan inside the transaction that
|
||||
# serves this request, so an answer at all means it was created; a
|
||||
# provider that already has one is refused with a 409 instead, which
|
||||
# leaves this tool as an error.
|
||||
#
|
||||
# The task in the answer is the FIRST scan run, queued to start a few
|
||||
# seconds later, not the schedule. Its state therefore says nothing
|
||||
# about whether the schedule was created, and reporting it as the
|
||||
# outcome would call a schedule that exists a failure and invite a
|
||||
# retry that can only hit that 409.
|
||||
first_run_state = (
|
||||
task_response.get("data", {}).get("attributes", {}).get("state", None)
|
||||
)
|
||||
|
||||
if task_state == "available":
|
||||
return_message = "Daily schedule created successfully. The schedule is being set up in the background. Use prowler_list_scans with provider_id filter to view scheduled scans."
|
||||
else:
|
||||
return_message = "Daily schedule creation failed. Please try again later."
|
||||
message = (
|
||||
f"Daily schedule created for provider {provider_id}. Prowler will scan it "
|
||||
"every 24 hours until the provider is deleted. Use prowler_list_scans with "
|
||||
"this provider_id and trigger='scheduled' to view its scheduled scans."
|
||||
)
|
||||
|
||||
if first_run_state in ("failed", "cancelled"):
|
||||
# Worth saying: the schedule stands, but the run that was supposed to
|
||||
# start now will not produce findings, and only a manual scan fills
|
||||
# the gap before tomorrow.
|
||||
message = (
|
||||
f"{message} Note that the first scan, which Prowler starts immediately, "
|
||||
f"ended as '{first_run_state}'. The daily schedule is unaffected, but "
|
||||
"use prowler_trigger_scan if you need results before the next run."
|
||||
)
|
||||
|
||||
return ScheduleCreationResult(
|
||||
scheduled=(task_state == "available"),
|
||||
message=return_message,
|
||||
first_run_state=first_run_state,
|
||||
message=message,
|
||||
).model_dump()
|
||||
|
||||
async def update_scan(
|
||||
self,
|
||||
scan_id: str = Field(
|
||||
scan_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the scan to update, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find the scan ID if you only know the provider or scan name. Returns an error if the scan ID is invalid or not found."
|
||||
),
|
||||
name: str = Field(
|
||||
name: NonBlankStr = Field(
|
||||
description="New human-friendly name for the scan (3-100 characters). Use descriptive names to improve organization and tracking, e.g., 'Production Security Audit - Q4 2025', 'Post-Deployment Compliance Check'. IMPORTANT: Only the scan name can be updated - other attributes (state, progress, duration) are read-only and managed by the system."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_app.models.users import (
|
||||
DetailedUser,
|
||||
UsersListResponse,
|
||||
@@ -79,7 +80,7 @@ class UsersTools(BaseTool):
|
||||
|
||||
async def get_user(
|
||||
self,
|
||||
user_id: str = Field(
|
||||
user_id: NonBlankStr = Field(
|
||||
description="Prowler's internal UUID (v4) for the user to retrieve. Use `prowler_list_users` to find user IDs if you only know a name or email."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -118,7 +118,21 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
|
||||
if detail:
|
||||
message = f"{message} - {detail}"
|
||||
|
||||
raise ProwlerAPIError(message, status, detail=detail) from e
|
||||
# Carried on the exception, not into the message: a tool needs the
|
||||
# body to tell an answer with an error status -- a 404 holding the
|
||||
# empty result of a query that matched nothing -- apart from a
|
||||
# request that actually failed.
|
||||
try:
|
||||
body = e.response.json()
|
||||
except ValueError:
|
||||
body = None
|
||||
|
||||
raise ProwlerAPIError(
|
||||
message,
|
||||
status,
|
||||
detail=detail,
|
||||
payload=body if isinstance(body, dict) else None,
|
||||
) from e
|
||||
except httpx.RequestError as e:
|
||||
# No answer came back, so whether the request was applied is unknown.
|
||||
logger.error(f"Error during {method.value} {path}: {e}")
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from fastmcp.server.dependencies import get_http_headers
|
||||
|
||||
from prowler_mcp_server import __version__
|
||||
from prowler_mcp_server.lib.errors import CredentialError
|
||||
from prowler_mcp_server.lib.logger import logger
|
||||
|
||||
|
||||
@@ -64,7 +65,12 @@ class ProwlerAppAuth:
|
||||
|
||||
# Decode and parse JSON
|
||||
decoded = base64.b64decode(base64_payload).decode("utf-8")
|
||||
return json.loads(decoded)
|
||||
payload = json.loads(decoded)
|
||||
|
||||
# A JWT payload is a JSON object. A list or a scalar decodes just as
|
||||
# cleanly, so the type is checked here rather than left to blow up as
|
||||
# an AttributeError on the first claim read.
|
||||
return payload if isinstance(payload, dict) else None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse JWT token: {e}")
|
||||
return None
|
||||
@@ -76,14 +82,16 @@ class ProwlerAppAuth:
|
||||
authorization_header = headers.get("authorization", None)
|
||||
|
||||
if not authorization_header:
|
||||
raise ValueError("No authorization header provided")
|
||||
raise CredentialError("No Authorization header was sent")
|
||||
|
||||
# Extract token from Bearer header
|
||||
if authorization_header.startswith("Bearer "):
|
||||
token = authorization_header.replace("Bearer ", "")
|
||||
else:
|
||||
raise ValueError(
|
||||
"Invalid authorization header format. Expected 'Bearer <token>'"
|
||||
# Extract token from Bearer header. Authentication scheme names are
|
||||
# case-insensitive (RFC 7235), and only the scheme prefix is removed:
|
||||
# a token that happens to contain the word again keeps it.
|
||||
scheme, _, credential = authorization_header.partition(" ")
|
||||
token = credential.strip()
|
||||
if scheme.lower() != "bearer" or not token:
|
||||
raise CredentialError(
|
||||
"The Authorization header is not in 'Bearer <token>' form"
|
||||
)
|
||||
|
||||
# Check if it's an API key or JWT token
|
||||
@@ -94,17 +102,29 @@ class ProwlerAppAuth:
|
||||
# JWT token - validate and check expiration
|
||||
payload = self._parse_jwt(token)
|
||||
if not payload:
|
||||
raise ValueError("Invalid JWT token format")
|
||||
raise CredentialError("The token is not a readable JWT")
|
||||
|
||||
# Check if token is expired. `exp` is a numeric date in the
|
||||
# spec, so a missing or non-numeric one makes the token
|
||||
# unusable rather than merely stale -- comparing it would raise
|
||||
# a TypeError and leave the failure masked as unclassified.
|
||||
exp = payload.get("exp")
|
||||
if isinstance(exp, bool) or not isinstance(exp, (int, float)):
|
||||
raise CredentialError(
|
||||
"The token carries no readable 'exp' expiration claim"
|
||||
)
|
||||
|
||||
# Check if token is expired
|
||||
now = int(datetime.now().timestamp())
|
||||
exp = payload.get("exp", 0)
|
||||
if exp <= now:
|
||||
raise ValueError("Token has expired")
|
||||
raise CredentialError("The token has expired")
|
||||
|
||||
return token
|
||||
else:
|
||||
raise ValueError(f"Invalid mode: {self.mode}")
|
||||
# PROWLER_MCP_TRANSPORT_MODE holds something this server does not
|
||||
# support. Nothing about a call caused it and nothing about a call
|
||||
# can fix it, so it stays unclassified: masked for the model, logged
|
||||
# for whoever runs the server.
|
||||
raise RuntimeError(f"Invalid mode: {self.mode}")
|
||||
|
||||
async def get_valid_token(self) -> str:
|
||||
"""Get a valid token (API key or JWT token)."""
|
||||
|
||||
@@ -2,6 +2,7 @@ import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from prowler_mcp_server import __version__
|
||||
from prowler_mcp_server.lib.errors import parse_json_response
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
@@ -58,8 +59,7 @@ class ProwlerDocsSearchEngine:
|
||||
)
|
||||
|
||||
def search(self, query: str, page_size: int = 5) -> list[SearchResult]:
|
||||
"""
|
||||
Search documentation using Mintlify API.
|
||||
"""Search documentation using Mintlify API.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
@@ -69,82 +69,85 @@ class ProwlerDocsSearchEngine:
|
||||
|
||||
Returns:
|
||||
list of search results
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If the search request failed, which is not the same
|
||||
answer as no matches
|
||||
UpstreamInvalidResponse: If the answer is not JSON, which is the
|
||||
documentation site's fault and not the search term's
|
||||
"""
|
||||
try:
|
||||
# Make request to Mintlify API
|
||||
response = self.mintlify_client.post(
|
||||
self.api_base_url,
|
||||
json={"query": query, "filters": {}},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# Make request to Mintlify API
|
||||
response = self.mintlify_client.post(
|
||||
self.api_base_url,
|
||||
json={"query": query, "filters": {}},
|
||||
)
|
||||
response.raise_for_status()
|
||||
# Not `response.json()`: the decode error it raises is a ValueError, which
|
||||
# the shared classifier reads as a malformed argument and answers by
|
||||
# telling the caller to fix a search term that was never the problem.
|
||||
data = parse_json_response(response)
|
||||
|
||||
# Parse results
|
||||
results = []
|
||||
for match in data.get("results", [])[:page_size]:
|
||||
metadata = match.get("metadata", {})
|
||||
breadcrumbs = metadata.get("breadcrumbs", [])
|
||||
doc_path = match.get("page", "")
|
||||
# Parse results
|
||||
results = []
|
||||
for match in data.get("results", [])[:page_size]:
|
||||
metadata = match.get("metadata", {})
|
||||
breadcrumbs = metadata.get("breadcrumbs", [])
|
||||
doc_path = match.get("page", "")
|
||||
|
||||
# A match is one section of a page rather than the page: the
|
||||
# heading it was found under is its header, and the page's own
|
||||
# title is the last step of its breadcrumb trail.
|
||||
section = match.get("header", "")
|
||||
title = breadcrumbs[-1] if breadcrumbs else section
|
||||
# A match is one section of a page rather than the page: the
|
||||
# heading it was found under is its header, and the page's own
|
||||
# title is the last step of its breadcrumb trail.
|
||||
section = match.get("header", "")
|
||||
title = breadcrumbs[-1] if breadcrumbs else section
|
||||
|
||||
# Sent as "" for the section a page opens with and as null for
|
||||
# the pages that have no anchors at all; both mean the page.
|
||||
anchor = metadata.get("hash")
|
||||
url = f"{self.docs_base_url}/{doc_path}"
|
||||
if anchor:
|
||||
url = f"{url}#{anchor}"
|
||||
# Sent as "" for the section a page opens with and as null for
|
||||
# the pages that have no anchors at all; both mean the page.
|
||||
anchor = metadata.get("hash")
|
||||
url = f"{self.docs_base_url}/{doc_path}"
|
||||
if anchor:
|
||||
url = f"{url}#{anchor}"
|
||||
|
||||
results.append(
|
||||
SearchResult(
|
||||
path=doc_path,
|
||||
title=title,
|
||||
section=section,
|
||||
breadcrumbs=breadcrumbs,
|
||||
url=url,
|
||||
excerpt=match.get("content", ""),
|
||||
score=match.get("score", 0.0),
|
||||
)
|
||||
results.append(
|
||||
SearchResult(
|
||||
path=doc_path,
|
||||
title=title,
|
||||
section=section,
|
||||
breadcrumbs=breadcrumbs,
|
||||
url=url,
|
||||
excerpt=match.get("content", ""),
|
||||
score=match.get("score", 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
# Return empty list on error
|
||||
print(f"Search error: {e}")
|
||||
return []
|
||||
return results
|
||||
|
||||
def get_document(self, doc_path: str) -> str | None:
|
||||
"""
|
||||
Get full document content from Mintlify documentation.
|
||||
"""Get full document content from Mintlify documentation.
|
||||
|
||||
Args:
|
||||
doc_path: Path to the documentation file (e.g., "getting-started/installation")
|
||||
|
||||
Returns:
|
||||
Full markdown content of the documentation, or None if not found
|
||||
Full markdown content of the documentation, or None if there is no
|
||||
page at that path
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If the fetch failed for any reason other than a 404
|
||||
"""
|
||||
try:
|
||||
# Clean up the path
|
||||
doc_path = doc_path.rstrip("/")
|
||||
# Clean up the path
|
||||
doc_path = doc_path.rstrip("/")
|
||||
|
||||
# Add .md extension if not present (Mintlify serves both .md and .mdx)
|
||||
if not doc_path.endswith(".md"):
|
||||
doc_path = f"{doc_path}.md"
|
||||
# Add .md extension if not present (Mintlify serves both .md and .mdx)
|
||||
if not doc_path.endswith(".md"):
|
||||
doc_path = f"{doc_path}.md"
|
||||
|
||||
# Construct Mintlify URL
|
||||
url = f"{self.docs_base_url}/{doc_path}"
|
||||
# Construct Mintlify URL
|
||||
url = f"{self.docs_base_url}/{doc_path}"
|
||||
|
||||
# Fetch the documentation page
|
||||
response = self.docs_client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.text
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching document: {e}")
|
||||
# Fetch the documentation page
|
||||
response = self.docs_client.get(url)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
|
||||
return response.text
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.prowler_documentation.search_engine import (
|
||||
ProwlerDocsSearchEngine,
|
||||
)
|
||||
|
||||
# Initialize FastMCP server
|
||||
docs_mcp_server = FastMCP("prowler-docs")
|
||||
docs_mcp_server = FastMCP("prowler-docs", mask_error_details=True)
|
||||
prowler_docs_search_engine = ProwlerDocsSearchEngine()
|
||||
|
||||
|
||||
@docs_mcp_server.tool()
|
||||
def search(
|
||||
term: str = Field(description="The term to search for in the documentation"),
|
||||
term: NonBlankStr = Field(
|
||||
description="The term to search for in the documentation"
|
||||
),
|
||||
page_size: int = Field(
|
||||
5,
|
||||
description="Number of top results to return. It must be between 1 and 20.",
|
||||
@@ -39,7 +43,7 @@ def search(
|
||||
|
||||
@docs_mcp_server.tool()
|
||||
def get_document(
|
||||
doc_path: str = Field(
|
||||
doc_path: NonBlankStr = Field(
|
||||
description="Path to the documentation file to retrieve. It is the same as the 'path' field of the search results. Use `prowler_docs_search` to find the path first."
|
||||
),
|
||||
) -> dict[str, str]:
|
||||
@@ -53,6 +57,10 @@ def get_document(
|
||||
"""
|
||||
content: str | None = prowler_docs_search_engine.get_document(doc_path)
|
||||
if content is None:
|
||||
return {"error": f"Document '{doc_path}' not found."}
|
||||
else:
|
||||
return {"content": content}
|
||||
# No `from`: this names the path asked for and the tool that produces a
|
||||
# valid one, neither of which the shared classifier can know.
|
||||
raise ToolError(
|
||||
f"The Prowler documentation has no page at '{doc_path}'. Use "
|
||||
"prowler_docs_search and pass the 'path' field of a result verbatim."
|
||||
)
|
||||
return {"content": content}
|
||||
|
||||
@@ -6,12 +6,19 @@ Provides access to Prowler Hub API for security checks and compliance frameworks
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server import __version__
|
||||
from prowler_mcp_server.lib.errors import (
|
||||
UpstreamInvalidResponse,
|
||||
parse_json_response,
|
||||
)
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.lib.urls import url_path
|
||||
|
||||
# Initialize FastMCP for Prowler Hub
|
||||
hub_mcp_server = FastMCP("prowler-hub")
|
||||
hub_mcp_server = FastMCP("prowler-hub", mask_error_details=True)
|
||||
|
||||
# API base URL
|
||||
BASE_URL = "https://hub.prowler.com/api"
|
||||
@@ -26,6 +33,19 @@ prowler_hub_client = httpx.Client(
|
||||
},
|
||||
)
|
||||
|
||||
# Sentences for the not-found cases. They are authored here, and raised as a
|
||||
# ToolError without a `from` clause, because they name the resource the caller
|
||||
# asked for and the next tool to reach for -- neither of which the shared
|
||||
# classifier in lib/errors.py can know.
|
||||
_CHECK_NOT_FOUND = (
|
||||
"No check with the ID '{check_id}' exists in Prowler Hub. Use "
|
||||
"prowler_hub_semantic_search_checks to find the right ID."
|
||||
)
|
||||
_COMPLIANCE_NOT_FOUND = (
|
||||
"No compliance framework with the ID '{compliance_id}' exists in Prowler Hub. "
|
||||
"Use prowler_hub_semantic_search_compliances to find the right ID."
|
||||
)
|
||||
|
||||
# GitHub raw content base URL for Prowler checks
|
||||
GITHUB_RAW_BASE = (
|
||||
"https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/"
|
||||
@@ -42,6 +62,21 @@ github_raw_client = httpx.Client(
|
||||
)
|
||||
|
||||
|
||||
def _get_hub_endpoint(
|
||||
*path_segments: str, params: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
"""GET a Prowler Hub endpoint, named as one argument per path segment.
|
||||
|
||||
Args:
|
||||
*path_segments: The endpoint path segments, in order.
|
||||
params: Query parameters for the request.
|
||||
|
||||
Returns:
|
||||
The response unread, so a caller can tell a 404 from a failed request.
|
||||
"""
|
||||
return prowler_hub_client.get(url_path(*path_segments), params=params)
|
||||
|
||||
|
||||
def github_check_path(provider_id: str, check_id: str, suffix: str) -> str:
|
||||
"""Build the GitHub raw URL for a given check artifact suffix using provider
|
||||
and check_id.
|
||||
@@ -52,7 +87,83 @@ def github_check_path(provider_id: str, check_id: str, suffix: str) -> str:
|
||||
service_id = check_id.split("_", 1)[0]
|
||||
except IndexError:
|
||||
service_id = check_id
|
||||
return f"{GITHUB_RAW_BASE}/{provider_id}/services/{service_id}/{check_id}/{check_id}{suffix}"
|
||||
path = url_path(provider_id, "services", service_id, check_id, check_id)
|
||||
return f"{GITHUB_RAW_BASE}{path}{suffix}"
|
||||
|
||||
|
||||
def _hub_provider_for_check(check_id: str) -> str | None:
|
||||
"""Ask Prowler Hub which provider it lists a check under.
|
||||
|
||||
Args:
|
||||
check_id: Check ID the caller asked for
|
||||
|
||||
Returns:
|
||||
The provider the Hub lists the check under, or None when the Hub knows
|
||||
no such check.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: The Hub could not be reached.
|
||||
UpstreamInvalidResponse: The Hub answered with a body that is not JSON.
|
||||
ValueError: The Hub answered with something that names no provider.
|
||||
"""
|
||||
response = _get_hub_endpoint("check", check_id)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
check = parse_json_response(response)
|
||||
|
||||
# An empty body is how the Hub reports an unknown ID on some routes, so it
|
||||
# is read the same way get_check_details reads it: no such check.
|
||||
if not isinstance(check, dict) or not check:
|
||||
return None
|
||||
|
||||
provider = check.get("provider")
|
||||
if isinstance(provider, str) and provider.strip():
|
||||
return provider
|
||||
# A check the Hub returned without a provider tells us nothing about the
|
||||
# provider the caller asked for, so it counts as unanswered rather than as
|
||||
# a check that does not exist.
|
||||
raise ValueError(f"Prowler Hub listed check '{check_id}' without a provider")
|
||||
|
||||
|
||||
def _explain_missing_check_file(
|
||||
provider_id: str,
|
||||
check_id: str,
|
||||
*,
|
||||
when_check_belongs_here: str,
|
||||
when_unverified: str,
|
||||
) -> str:
|
||||
"""Explain a 404 from GitHub for one of a check's source files.
|
||||
|
||||
GitHub answers 404 to three different mistakes, an ID that exists nowhere,
|
||||
an ID that exists under a different provider, and an ID that exists right
|
||||
here whose file is simply absent, and cannot tell them apart. Prowler Hub
|
||||
can, so it is asked before anything is claimed about the ID.
|
||||
|
||||
Args:
|
||||
provider_id: Provider the caller asked for
|
||||
check_id: Check the caller asked for
|
||||
when_check_belongs_here: Message for the case where the Hub confirms the
|
||||
check does belong to this provider
|
||||
when_unverified: Message for the case where the Hub could not be asked
|
||||
|
||||
Returns:
|
||||
The sentence to fail the tool with
|
||||
"""
|
||||
try:
|
||||
hub_provider = _hub_provider_for_check(check_id)
|
||||
except (httpx.HTTPError, UpstreamInvalidResponse, ValueError):
|
||||
return when_unverified
|
||||
|
||||
if hub_provider is None:
|
||||
return _CHECK_NOT_FOUND.format(check_id=check_id)
|
||||
if hub_provider != provider_id:
|
||||
return (
|
||||
f"Provider '{provider_id}' has no check '{check_id}'. Prowler Hub lists "
|
||||
f"that check under provider '{hub_provider}', so retry with "
|
||||
f"provider_id='{hub_provider}'."
|
||||
)
|
||||
return when_check_belongs_here
|
||||
|
||||
|
||||
# Security Check Tools
|
||||
@@ -122,34 +233,27 @@ async def list_checks(
|
||||
if compliances:
|
||||
params["compliances"] = ",".join(compliances)
|
||||
|
||||
try:
|
||||
response = prowler_hub_client.get("/check", params=params)
|
||||
response.raise_for_status()
|
||||
checks = response.json()
|
||||
response = _get_hub_endpoint("check", params=params)
|
||||
response.raise_for_status()
|
||||
checks = parse_json_response(response)
|
||||
|
||||
# Return checks as a lightweight list
|
||||
checks_list = []
|
||||
for check in checks:
|
||||
check_data = {
|
||||
"id": check["id"],
|
||||
"provider": check["provider"],
|
||||
"title": check["title"],
|
||||
"severity": check["severity"],
|
||||
}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
# Return checks as a lightweight list
|
||||
checks_list = []
|
||||
for check in checks:
|
||||
check_data = {
|
||||
"id": check["id"],
|
||||
"provider": check["provider"],
|
||||
"title": check["title"],
|
||||
"severity": check["severity"],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def semantic_search_checks(
|
||||
term: str = Field(
|
||||
term: NonBlankStr = Field(
|
||||
description="Search term. Examples: 'public access', 'encryption', 'MFA', 'logging'.",
|
||||
),
|
||||
) -> dict:
|
||||
@@ -181,34 +285,27 @@ async def semantic_search_checks(
|
||||
2. Use `prowler_hub_list_checks` with filters for more targeted browsing
|
||||
3. Use `prowler_hub_get_check_details` to get complete information for a specific check
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get("/check/search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
checks = response.json()
|
||||
response = _get_hub_endpoint("check", "search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
checks = parse_json_response(response)
|
||||
|
||||
# Return checks as a lightweight list
|
||||
checks_list = []
|
||||
for check in checks:
|
||||
check_data = {
|
||||
"id": check["id"],
|
||||
"provider": check["provider"],
|
||||
"title": check["title"],
|
||||
"severity": check["severity"],
|
||||
}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
# Return checks as a lightweight list
|
||||
checks_list = []
|
||||
for check in checks:
|
||||
check_data = {
|
||||
"id": check["id"],
|
||||
"provider": check["provider"],
|
||||
"title": check["title"],
|
||||
"severity": check["severity"],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_check_details(
|
||||
check_id: str = Field(
|
||||
check_id: NonBlankStr = Field(
|
||||
description="The check ID to retrieve details for. Example: 's3_bucket_level_public_access_block'"
|
||||
),
|
||||
) -> dict:
|
||||
@@ -273,83 +370,83 @@ async def get_check_details(
|
||||
2. Use this tool with the check 'id' to get complete information including remediation guidance
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get(f"/check/{check_id}")
|
||||
response = _get_hub_endpoint("check", check_id)
|
||||
response.raise_for_status()
|
||||
check = response.json()
|
||||
|
||||
if not check:
|
||||
return {"error": f"Check '{check_id}' not found"}
|
||||
|
||||
# Build response with only non-empty fields to save tokens
|
||||
result = {}
|
||||
|
||||
# Core fields
|
||||
result["id"] = check["id"]
|
||||
if check.get("title"):
|
||||
result["title"] = check["title"]
|
||||
if check.get("description"):
|
||||
result["description"] = check["description"]
|
||||
if check.get("provider"):
|
||||
result["provider"] = check["provider"]
|
||||
if check.get("service"):
|
||||
result["service"] = check["service"]
|
||||
if check.get("severity"):
|
||||
result["severity"] = check["severity"]
|
||||
if check.get("risk"):
|
||||
result["risk"] = check["risk"]
|
||||
if check.get("resource_type"):
|
||||
result["resource_type"] = check["resource_type"]
|
||||
|
||||
# List fields
|
||||
if check.get("reference"):
|
||||
result["reference"] = check["reference"]
|
||||
if check.get("additional_urls"):
|
||||
result["additional_urls"] = check["additional_urls"]
|
||||
if check.get("services_required"):
|
||||
result["services_required"] = check["services_required"]
|
||||
if check.get("categories"):
|
||||
result["categories"] = check["categories"]
|
||||
if check.get("compliances"):
|
||||
result["compliances"] = check["compliances"]
|
||||
|
||||
# Other fields
|
||||
if check.get("notes"):
|
||||
result["notes"] = check["notes"]
|
||||
if check.get("related_url"):
|
||||
result["related_url"] = check["related_url"]
|
||||
if check.get("fixer") is not None:
|
||||
result["fixer"] = check["fixer"]
|
||||
|
||||
# Remediation - filter out empty nested values
|
||||
remediation = check.get("remediation", {})
|
||||
if remediation:
|
||||
filtered_remediation = {}
|
||||
for key, value in remediation.items():
|
||||
if value and isinstance(value, dict):
|
||||
# Filter out empty values within nested dict
|
||||
filtered_value = {k: v for k, v in value.items() if v}
|
||||
if filtered_value:
|
||||
filtered_remediation[key] = filtered_value
|
||||
elif value:
|
||||
filtered_remediation[key] = value
|
||||
if filtered_remediation:
|
||||
result["remediation"] = filtered_remediation
|
||||
|
||||
return result
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
if e.response.status_code == 404:
|
||||
# No `from`: this names the check, which the shared classifier cannot.
|
||||
raise ToolError(_CHECK_NOT_FOUND.format(check_id=check_id))
|
||||
raise
|
||||
|
||||
check = parse_json_response(response)
|
||||
|
||||
if not check:
|
||||
raise ToolError(_CHECK_NOT_FOUND.format(check_id=check_id))
|
||||
|
||||
# Build response with only non-empty fields to save tokens
|
||||
result = {}
|
||||
|
||||
# Core fields
|
||||
result["id"] = check["id"]
|
||||
if check.get("title"):
|
||||
result["title"] = check["title"]
|
||||
if check.get("description"):
|
||||
result["description"] = check["description"]
|
||||
if check.get("provider"):
|
||||
result["provider"] = check["provider"]
|
||||
if check.get("service"):
|
||||
result["service"] = check["service"]
|
||||
if check.get("severity"):
|
||||
result["severity"] = check["severity"]
|
||||
if check.get("risk"):
|
||||
result["risk"] = check["risk"]
|
||||
if check.get("resource_type"):
|
||||
result["resource_type"] = check["resource_type"]
|
||||
|
||||
# List fields
|
||||
if check.get("reference"):
|
||||
result["reference"] = check["reference"]
|
||||
if check.get("additional_urls"):
|
||||
result["additional_urls"] = check["additional_urls"]
|
||||
if check.get("services_required"):
|
||||
result["services_required"] = check["services_required"]
|
||||
if check.get("categories"):
|
||||
result["categories"] = check["categories"]
|
||||
if check.get("compliances"):
|
||||
result["compliances"] = check["compliances"]
|
||||
|
||||
# Other fields
|
||||
if check.get("notes"):
|
||||
result["notes"] = check["notes"]
|
||||
if check.get("related_url"):
|
||||
result["related_url"] = check["related_url"]
|
||||
if check.get("fixer") is not None:
|
||||
result["fixer"] = check["fixer"]
|
||||
|
||||
# Remediation - filter out empty nested values
|
||||
remediation = check.get("remediation", {})
|
||||
if remediation:
|
||||
filtered_remediation = {}
|
||||
for key, value in remediation.items():
|
||||
if value and isinstance(value, dict):
|
||||
# Filter out empty values within nested dict
|
||||
filtered_value = {k: v for k, v in value.items() if v}
|
||||
if filtered_value:
|
||||
filtered_remediation[key] = filtered_value
|
||||
elif value:
|
||||
filtered_remediation[key] = value
|
||||
if filtered_remediation:
|
||||
result["remediation"] = filtered_remediation
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_check_code(
|
||||
provider_id: str = Field(
|
||||
provider_id: NonBlankStr = Field(
|
||||
description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
|
||||
),
|
||||
check_id: str = Field(
|
||||
check_id: NonBlankStr = Field(
|
||||
description="The check ID. Example: 's3_bucket_public_access'. Get IDs from `prowler_hub_list_checks` or `prowler_hub_search_checks`.",
|
||||
),
|
||||
) -> dict:
|
||||
@@ -363,46 +460,54 @@ async def get_check_code(
|
||||
"content": "Python source code of the check implementation"
|
||||
}
|
||||
"""
|
||||
if provider_id and check_id:
|
||||
url = github_check_path(provider_id, check_id, ".py")
|
||||
try:
|
||||
resp = github_raw_client.get(url)
|
||||
resp.raise_for_status()
|
||||
return {
|
||||
"content": resp.text,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return {
|
||||
"error": f"Check {check_id} not found in Prowler",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": "Provider ID and check ID are required",
|
||||
}
|
||||
url = github_check_path(provider_id, check_id, ".py")
|
||||
try:
|
||||
resp = github_raw_client.get(url)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
# No `from`: this names the check and the provider that does have
|
||||
# it, neither of which the shared classifier in lib/errors.py knows.
|
||||
raise ToolError(
|
||||
_explain_missing_check_file(
|
||||
provider_id,
|
||||
check_id,
|
||||
when_check_belongs_here=(
|
||||
f"Prowler Hub lists check '{check_id}' under provider "
|
||||
f"'{provider_id}', but prowler-cloud/prowler has no source file "
|
||||
"for it on the master branch. The check may have been renamed or "
|
||||
"moved since the Hub last indexed it."
|
||||
),
|
||||
when_unverified=(
|
||||
f"Provider '{provider_id}' has no check '{check_id}' in "
|
||||
"prowler-cloud/prowler, and Prowler Hub could not be asked which "
|
||||
"provider does. Either the ID is wrong or the check belongs to "
|
||||
"another provider, prowler_hub_get_check_details reports the "
|
||||
"provider a check belongs to."
|
||||
),
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
return {
|
||||
"content": resp.text,
|
||||
}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_check_fixer(
|
||||
provider_id: str = Field(
|
||||
provider_id: NonBlankStr = Field(
|
||||
description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
|
||||
),
|
||||
check_id: str = Field(
|
||||
check_id: NonBlankStr = Field(
|
||||
description="The check ID. Example: 's3_bucket_public_access'. Get IDs from `prowler_hub_list_checks` or `prowler_hub_search_checks`.",
|
||||
),
|
||||
) -> dict:
|
||||
"""Fetch the auto-remediation (fixer) code for a Prowler security check.
|
||||
|
||||
IMPORTANT: Not all checks have fixers. A "fixer not found" response means the check
|
||||
doesn't have auto-remediation code - this is normal for many checks.
|
||||
IMPORTANT: Not all checks have fixers. A check with no auto-remediation code fails
|
||||
with a message saying so - this is normal for many checks and not a problem to
|
||||
report or retry.
|
||||
|
||||
Fixer code provides automated remediation that can fix security issues detected by checks.
|
||||
Use this to understand how to programmatically remediate findings.
|
||||
@@ -411,40 +516,37 @@ async def get_check_fixer(
|
||||
{
|
||||
"content": "Python source code of the auto-remediation implementation"
|
||||
}
|
||||
Or if no fixer exists:
|
||||
{
|
||||
"error": "Fixer not found for check {check_id}"
|
||||
}
|
||||
"""
|
||||
if provider_id and check_id:
|
||||
url = github_check_path(provider_id, check_id, "_fixer.py")
|
||||
try:
|
||||
resp = github_raw_client.get(url)
|
||||
if resp.status_code == 404:
|
||||
return {
|
||||
"error": f"Fixer not found for check {check_id}",
|
||||
}
|
||||
resp.raise_for_status()
|
||||
return {
|
||||
"content": resp.text,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return {
|
||||
"error": f"Check {check_id} not found in Prowler",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": "Provider ID and check ID are required",
|
||||
}
|
||||
url = github_check_path(provider_id, check_id, "_fixer.py")
|
||||
try:
|
||||
resp = github_raw_client.get(url)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
# "No fixer" is only one of the reasons the file is missing, and the
|
||||
# others are the caller's to fix, so they are told apart first.
|
||||
raise ToolError(
|
||||
_explain_missing_check_file(
|
||||
provider_id,
|
||||
check_id,
|
||||
when_check_belongs_here=(
|
||||
f"Check {check_id} has no auto-remediation code. Many checks do "
|
||||
"not, and that is normal."
|
||||
),
|
||||
when_unverified=(
|
||||
f"Provider '{provider_id}' has no auto-remediation code for "
|
||||
f"check '{check_id}'. Many checks have none, and that is normal, "
|
||||
f"but Prowler Hub could not be asked whether the check belongs "
|
||||
f"to '{provider_id}' at all. Confirm it with "
|
||||
"prowler_hub_get_check_details if you expected a fixer."
|
||||
),
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
return {
|
||||
"content": resp.text,
|
||||
}
|
||||
|
||||
|
||||
# Compliance Framework Tools
|
||||
@@ -491,33 +593,26 @@ async def list_compliances(
|
||||
if provider:
|
||||
params["provider"] = ",".join(provider)
|
||||
|
||||
try:
|
||||
response = prowler_hub_client.get("/compliance", params=params)
|
||||
response.raise_for_status()
|
||||
compliances = response.json()
|
||||
response = _get_hub_endpoint("compliance", params=params)
|
||||
response.raise_for_status()
|
||||
compliances = parse_json_response(response)
|
||||
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def semantic_search_compliances(
|
||||
term: str = Field(
|
||||
term: NonBlankStr = Field(
|
||||
description="Search term. Examples: 'CIS', 'HIPAA', 'PCI', 'GDPR', 'SOC2', 'NIST'.",
|
||||
),
|
||||
) -> dict:
|
||||
@@ -542,33 +637,26 @@ async def semantic_search_compliances(
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get("/compliance/search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
compliances = response.json()
|
||||
response = _get_hub_endpoint("compliance", "search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
compliances = parse_json_response(response)
|
||||
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_compliance_details(
|
||||
compliance_id: str = Field(
|
||||
compliance_id: NonBlankStr = Field(
|
||||
description="The compliance framework ID to retrieve details for. Example: 'cis_4.0_aws'. Use `prowler_hub_list_compliances` or `prowler_hub_semantic_search_compliances` to find available compliance IDs.",
|
||||
),
|
||||
) -> dict:
|
||||
@@ -598,63 +686,60 @@ async def get_compliance_details(
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get(f"/compliance/{compliance_id}")
|
||||
response = _get_hub_endpoint("compliance", compliance_id)
|
||||
response.raise_for_status()
|
||||
compliance = response.json()
|
||||
|
||||
if not compliance:
|
||||
return {"error": f"Compliance '{compliance_id}' not found"}
|
||||
|
||||
# Build response with only non-empty fields to save tokens
|
||||
result = {}
|
||||
|
||||
# Core fields
|
||||
result["id"] = compliance["id"]
|
||||
if compliance.get("name"):
|
||||
result["name"] = compliance["name"]
|
||||
if compliance.get("framework"):
|
||||
result["framework"] = compliance["framework"]
|
||||
if compliance.get("provider"):
|
||||
result["provider"] = compliance["provider"]
|
||||
if compliance.get("version"):
|
||||
result["version"] = compliance["version"]
|
||||
if compliance.get("description"):
|
||||
result["description"] = compliance["description"]
|
||||
|
||||
# Numeric fields
|
||||
if compliance.get("total_checks"):
|
||||
result["total_checks"] = compliance["total_checks"]
|
||||
if compliance.get("total_requirements"):
|
||||
result["total_requirements"] = compliance["total_requirements"]
|
||||
|
||||
# Requirements - filter out empty nested values
|
||||
requirements = compliance.get("requirements", [])
|
||||
if requirements:
|
||||
filtered_requirements = []
|
||||
for req in requirements:
|
||||
filtered_req = {}
|
||||
if req.get("id"):
|
||||
filtered_req["id"] = req["id"]
|
||||
if req.get("name"):
|
||||
filtered_req["name"] = req["name"]
|
||||
if req.get("description"):
|
||||
filtered_req["description"] = req["description"]
|
||||
if req.get("checks"):
|
||||
filtered_req["checks"] = req["checks"]
|
||||
if filtered_req:
|
||||
filtered_requirements.append(filtered_req)
|
||||
if filtered_requirements:
|
||||
result["requirements"] = filtered_requirements
|
||||
|
||||
return result
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return {"error": f"Compliance '{compliance_id}' not found"}
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
raise ToolError(_COMPLIANCE_NOT_FOUND.format(compliance_id=compliance_id))
|
||||
raise
|
||||
|
||||
compliance = parse_json_response(response)
|
||||
|
||||
if not compliance:
|
||||
raise ToolError(_COMPLIANCE_NOT_FOUND.format(compliance_id=compliance_id))
|
||||
|
||||
# Build response with only non-empty fields to save tokens
|
||||
result = {}
|
||||
|
||||
# Core fields
|
||||
result["id"] = compliance["id"]
|
||||
if compliance.get("name"):
|
||||
result["name"] = compliance["name"]
|
||||
if compliance.get("framework"):
|
||||
result["framework"] = compliance["framework"]
|
||||
if compliance.get("provider"):
|
||||
result["provider"] = compliance["provider"]
|
||||
if compliance.get("version"):
|
||||
result["version"] = compliance["version"]
|
||||
if compliance.get("description"):
|
||||
result["description"] = compliance["description"]
|
||||
|
||||
# Numeric fields
|
||||
if compliance.get("total_checks"):
|
||||
result["total_checks"] = compliance["total_checks"]
|
||||
if compliance.get("total_requirements"):
|
||||
result["total_requirements"] = compliance["total_requirements"]
|
||||
|
||||
# Requirements - filter out empty nested values
|
||||
requirements = compliance.get("requirements", [])
|
||||
if requirements:
|
||||
filtered_requirements = []
|
||||
for req in requirements:
|
||||
filtered_req = {}
|
||||
if req.get("id"):
|
||||
filtered_req["id"] = req["id"]
|
||||
if req.get("name"):
|
||||
filtered_req["name"] = req["name"]
|
||||
if req.get("description"):
|
||||
filtered_req["description"] = req["description"]
|
||||
if req.get("checks"):
|
||||
filtered_req["checks"] = req["checks"]
|
||||
if filtered_req:
|
||||
filtered_requirements.append(filtered_req)
|
||||
if filtered_requirements:
|
||||
result["requirements"] = filtered_requirements
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Provider Tools
|
||||
@@ -683,32 +768,25 @@ async def list_providers() -> dict:
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get("/providers")
|
||||
response.raise_for_status()
|
||||
providers = response.json()
|
||||
response = _get_hub_endpoint("providers")
|
||||
response.raise_for_status()
|
||||
providers = parse_json_response(response)
|
||||
|
||||
providers_list = []
|
||||
for provider in providers:
|
||||
providers_list.append(
|
||||
{
|
||||
"id": provider["id"],
|
||||
"name": provider.get("name", ""),
|
||||
}
|
||||
)
|
||||
providers_list = []
|
||||
for provider in providers:
|
||||
providers_list.append(
|
||||
{
|
||||
"id": provider["id"],
|
||||
"name": provider.get("name", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {"count": len(providers), "providers": providers_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"count": len(providers), "providers": providers_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_provider_services(
|
||||
provider_id: str = Field(
|
||||
provider_id: NonBlankStr = Field(
|
||||
description="The provider ID to get services for. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
|
||||
),
|
||||
) -> dict:
|
||||
@@ -727,24 +805,20 @@ async def get_provider_services(
|
||||
"services": ["s3", "ec2", "iam", "rds", "lambda", ...]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get("/providers")
|
||||
response.raise_for_status()
|
||||
providers = response.json()
|
||||
response = _get_hub_endpoint("providers")
|
||||
response.raise_for_status()
|
||||
providers = parse_json_response(response)
|
||||
|
||||
for provider in providers:
|
||||
if provider["id"] == provider_id:
|
||||
return {
|
||||
"provider_id": provider["id"],
|
||||
"provider_name": provider.get("name", ""),
|
||||
"count": len(provider.get("services", [])),
|
||||
"services": provider.get("services", []),
|
||||
}
|
||||
for provider in providers:
|
||||
if provider["id"] == provider_id:
|
||||
return {
|
||||
"provider_id": provider["id"],
|
||||
"provider_name": provider.get("name", ""),
|
||||
"count": len(provider.get("services", [])),
|
||||
"services": provider.get("services", []),
|
||||
}
|
||||
|
||||
return {"error": f"Provider '{provider_id}' not found"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
known = ", ".join(sorted(str(provider["id"]) for provider in providers))
|
||||
raise ToolError(
|
||||
f"Prowler has no provider with the ID '{provider_id}'. Available: {known}."
|
||||
)
|
||||
|
||||
@@ -7,11 +7,18 @@ reaches a model is text this server produced.
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from prowler_mcp_server.lib.errors import InvalidArgument, _describe_failure
|
||||
from prowler_mcp_server.lib.errors import (
|
||||
CredentialError,
|
||||
InvalidArgument,
|
||||
UpstreamInvalidResponse,
|
||||
_describe_failure,
|
||||
parse_json_response,
|
||||
)
|
||||
from prowler_mcp_server.prowler_app.utils.api_client import (
|
||||
ProwlerAPIError,
|
||||
ProwlerAPIInvalidResponse,
|
||||
@@ -22,6 +29,42 @@ from tests.helpers.jsonapi import jsonapi_error
|
||||
LATEST = "/api/v1/findings/latest"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- json bodies
|
||||
|
||||
|
||||
def _answer(
|
||||
body: str, *, url: str = "https://hub.prowler.com/api/check"
|
||||
) -> httpx.Response:
|
||||
"""An answer as a client would hand it back, request attached."""
|
||||
return httpx.Response(200, text=body, request=httpx.Request("GET", url))
|
||||
|
||||
|
||||
def test_a_json_body_is_returned_as_it_is():
|
||||
"""The helper only classifies the failure; the success path is untouched."""
|
||||
assert parse_json_response(_answer('{"id": "s3_bucket_public_access"}')) == {
|
||||
"id": "s3_bucket_public_access"
|
||||
}
|
||||
|
||||
|
||||
def test_a_body_that_is_not_json_names_the_host_that_answered():
|
||||
"""Which upstream is misbehaving is the one useful fact here, and the shared
|
||||
helper is reached from every sub-server that reads an upstream directly."""
|
||||
with pytest.raises(UpstreamInvalidResponse) as raised:
|
||||
parse_json_response(_answer("<html><body>502 Bad Gateway</body></html>"))
|
||||
|
||||
assert raised.value.host == "hub.prowler.com"
|
||||
assert "Bad Gateway" not in str(raised.value)
|
||||
|
||||
|
||||
def test_a_body_that_is_not_json_is_not_a_valueerror():
|
||||
"""`JSONDecodeError` is a ValueError, and callers tell an upstream fault from
|
||||
a bad argument by type alone."""
|
||||
with pytest.raises(UpstreamInvalidResponse) as raised:
|
||||
parse_json_response(_answer("not json"))
|
||||
|
||||
assert not isinstance(raised.value, ValueError)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ classification
|
||||
|
||||
|
||||
@@ -90,6 +133,29 @@ def test_an_unreadable_api_answer_is_never_called_safe_to_repeat():
|
||||
assert "check the current state" in message
|
||||
|
||||
|
||||
def test_an_unreadable_upstream_answer_is_not_blamed_on_the_arguments():
|
||||
"""A `JSONDecodeError` from an upstream and one from an argument are the same
|
||||
exception and opposite instructions."""
|
||||
message = _describe_failure(
|
||||
UpstreamInvalidResponse("200 body is not JSON", host="hub.prowler.com")
|
||||
)
|
||||
|
||||
assert "hub.prowler.com" in message
|
||||
assert "could not read as JSON" in message
|
||||
assert "changing them will not help" in message
|
||||
|
||||
|
||||
def test_an_unreadable_upstream_answer_never_quotes_the_body():
|
||||
"""The body is someone else's text, so only the host and the status leave here."""
|
||||
message = _describe_failure(
|
||||
UpstreamInvalidResponse(
|
||||
"502 body is not JSON", host="raw.githubusercontent.com"
|
||||
)
|
||||
)
|
||||
|
||||
assert "body is not JSON" not in message
|
||||
|
||||
|
||||
def test_an_argument_this_server_rejected_is_repeated_verbatim():
|
||||
"""`InvalidArgument` exists to mark a message as one we wrote."""
|
||||
message = _describe_failure(
|
||||
@@ -99,6 +165,19 @@ def test_an_argument_this_server_rejected_is_repeated_verbatim():
|
||||
assert message == "page_size must be between 1 and 1000."
|
||||
|
||||
|
||||
def test_a_credential_caught_here_is_answered_like_the_401_it_would_have_got():
|
||||
"""It is not an argument problem, and saying so stops a pointless retry."""
|
||||
message = _describe_failure(CredentialError("the token has expired"))
|
||||
|
||||
assert "the token has expired" in message
|
||||
assert "changing the arguments will not help" in message
|
||||
|
||||
|
||||
def test_a_transport_this_server_cannot_serve_is_left_masked():
|
||||
"""No call caused a bad PROWLER_MCP_TRANSPORT_MODE and no call can fix it."""
|
||||
assert _describe_failure(RuntimeError("Invalid mode: websocket")) is None
|
||||
|
||||
|
||||
def test_a_pydantic_rejection_names_the_field_without_echoing_the_value():
|
||||
"""Pydantic quotes `input_value` back, and these tools take credentials."""
|
||||
|
||||
@@ -195,3 +274,34 @@ async def test_an_unreadable_api_answer_does_not_reach_the_agent_as_a_bad_argume
|
||||
assert result.isError is True
|
||||
assert "gateway timeout" not in result.content[0].text
|
||||
assert "argument" not in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_tool_specific_message_survives_masking(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A `ToolError` raised without a `from` clause is the final word."""
|
||||
mock_router.add("GET", "/api/v1/integrations/i1", json={"data": None})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_get_integration", {"integration_id": "i1"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "prowler_list_integrations" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_hub_tool_failure_says_which_host_refused_it(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""Hub failures arrive as raw httpx errors: host and status relayed, body not."""
|
||||
hub_router.add(
|
||||
"GET", "/api/check", status=503, text="<html>upstream nginx 10.1.2.3</html>"
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_hub_list_checks", {})
|
||||
|
||||
assert result.isError is True
|
||||
assert "hub.prowler.com" in result.content[0].text
|
||||
assert "10.1.2.3" not in result.content[0].text
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for the argument types every tool shares.
|
||||
|
||||
The bug these pin: "required" alone does not stop a blank identifier. A model
|
||||
that has no scan or query id to hand sends ``""`` rather than omitting the
|
||||
argument, and an unguarded empty string travels into a URL path or a request
|
||||
body -- where it comes back as a 404, or as an API rejection ("This field may
|
||||
not be blank") that names no argument and leaves the model with nothing to fix.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
|
||||
|
||||
SCAN_ID = "019ac0d6-90d5-73e9-9acf-c22e256f1bac"
|
||||
QUERIES = f"/api/v1/attack-paths-scans/{SCAN_ID}/queries"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query_id", ["", " "], ids=["empty", "whitespace-only"])
|
||||
async def test_a_blank_identifier_is_rejected_before_any_request_goes_out(
|
||||
mcp_root_server, mock_api_client, mock_router, query_id
|
||||
):
|
||||
"""The reported failure: a blank `query_id` reached Prowler as a 400.
|
||||
|
||||
The message has to name the argument. Prowler's own answer to the blank value
|
||||
("This field may not be blank") does not say which field, so the model had no
|
||||
way to tell `scan_id` from `query_id` from the reply.
|
||||
"""
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_run_attack_paths_query",
|
||||
{"scan_id": SCAN_ID, "query_id": query_id},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "query_id" in result.content[0].text
|
||||
assert mock_router.requests == []
|
||||
|
||||
|
||||
async def test_an_identifier_keeps_its_surrounding_whitespace_out_of_the_url(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A padded id is the same id, and a raw one would build a URL-escaped path."""
|
||||
mock_router.add("GET", QUERIES, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_list_attack_paths_queries", {"scan_id": f" {SCAN_ID} "}
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
assert mock_router.paths() == [f"GET {QUERIES}"]
|
||||
|
||||
|
||||
async def test_a_blank_optional_value_is_rejected_rather_than_written(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""An omitted optional means "leave it alone"; a blank one would blank the field.
|
||||
|
||||
The API refuses it, so the only difference an unguarded blank makes is a
|
||||
round trip and an error that names nothing.
|
||||
"""
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_update_mute_rule", {"rule_id": SCAN_ID, "name": ""}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "name" in result.content[0].text
|
||||
assert mock_router.requests == []
|
||||
|
||||
|
||||
async def test_an_omitted_optional_string_is_still_omitted(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""`NonBlankStr | None` must not turn "not provided" into a rejection."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
f"/api/v1/mute-rules/{SCAN_ID}",
|
||||
json={
|
||||
"data": jsonapi_resource(
|
||||
"mute-rules",
|
||||
SCAN_ID,
|
||||
{
|
||||
"name": "unchanged",
|
||||
"reason": "already reviewed",
|
||||
"enabled": True,
|
||||
"finding_uids": [],
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_update_mute_rule", {"rule_id": SCAN_ID}
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
|
||||
|
||||
async def test_every_required_string_argument_is_guarded_against_a_blank(
|
||||
mcp_root_server,
|
||||
):
|
||||
"""A guard only one tool carries is one the next tool will be written without.
|
||||
|
||||
Declared as `minLength` rather than checked inside each tool, so a client sees
|
||||
the constraint in the schema before it calls.
|
||||
"""
|
||||
async with Client(mcp_root_server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
unguarded = [
|
||||
f"{tool.name}.{name}"
|
||||
for tool in tools
|
||||
for name, schema in tool.inputSchema.get("properties", {}).items()
|
||||
# Plain required strings only. A union such as `dict | str` takes a JSON
|
||||
# string, where a blank is a parse failure the classifier already
|
||||
# explains, and a blank filter is a filter that matches everything.
|
||||
if schema.get("type") == "string"
|
||||
and name in tool.inputSchema.get("required", [])
|
||||
and schema.get("minLength") != 1
|
||||
]
|
||||
|
||||
assert unguarded == []
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for the shared URL path builder.
|
||||
|
||||
The bug these pin: an identifier interpolated into a path was resolved away by
|
||||
httpx per RFC 3986, so "../" reached an endpoint no tool meant to call.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler_mcp_server.lib.urls import path_segment, url_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("s3_bucket_public_access", "s3_bucket_public_access"),
|
||||
("cis_4.0_aws", "cis_4.0_aws"),
|
||||
("../../evil", "..%2F..%2Fevil"),
|
||||
("....//evil", "....%2F%2Fevil"),
|
||||
("%2e%2e%2f", "%252e%252e%252f"),
|
||||
("..;/", "..%3B%2F"),
|
||||
("s3/../evil", "s3%2F..%2Fevil"),
|
||||
("evil?fields=all", "evil%3Ffields%3Dall"),
|
||||
("evil#frag", "evil%23frag"),
|
||||
("evil\\wrong", "evil%5Cwrong"),
|
||||
("two words", "two%20words"),
|
||||
],
|
||||
ids=[
|
||||
"plain",
|
||||
"dots-in-a-name",
|
||||
"traversal",
|
||||
"stripped-filter-bypass",
|
||||
"already-encoded",
|
||||
"path-parameter",
|
||||
"mid-path",
|
||||
"query",
|
||||
"fragment",
|
||||
"backslash",
|
||||
"space",
|
||||
],
|
||||
)
|
||||
def test_a_segment_survives_as_a_name_and_never_as_syntax(value, expected):
|
||||
"""A real ID passes through untouched; URL syntax comes back as characters."""
|
||||
assert path_segment(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [".", ".."], ids=["here", "up-one"])
|
||||
def test_a_segment_of_nothing_but_dots_is_escaped_rather_than_left_to_resolve(value):
|
||||
"""`quote` keeps a dot, so a segment of only dots would still resolve away."""
|
||||
assert path_segment(value) == value.replace(".", "%2E")
|
||||
|
||||
|
||||
def test_a_path_is_the_segments_it_was_given_and_no_others():
|
||||
"""One argument per segment, so no call site has to encode anything."""
|
||||
assert url_path("users", "../../evil", "roles") == "/users/..%2F..%2Fevil/roles"
|
||||
|
||||
|
||||
def test_a_single_segment_path_keeps_its_leading_slash():
|
||||
"""Every caller joins this onto a base URL that ends without a slash."""
|
||||
assert url_path("providers") == "/providers"
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Tests for the Attack Paths tools.
|
||||
|
||||
An Attack Paths scan is a separate resource from a regular scan, with IDs of its
|
||||
own, and Prowler only creates one for an AWS provider. So the 404 these tools get
|
||||
is almost always a regular scan ID passed where an Attack Paths one belongs --
|
||||
and Prowler's own reason for it, a bare "Not found.", names neither the resource
|
||||
it looked in nor the tool that returns the right ID.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
|
||||
|
||||
QUERIES = "/api/v1/attack-paths-scans/s1/queries"
|
||||
|
||||
|
||||
async def test_an_id_that_is_not_an_attack_paths_scan_says_which_tool_returns_one(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Relaying "Not found." sends an agent to re-check an ID it cannot fix.
|
||||
|
||||
The reply has to name the confusion it stands for: regular scan IDs do not
|
||||
resolve here, and only AWS providers have an Attack Paths scan at all.
|
||||
"""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
QUERIES,
|
||||
status=404,
|
||||
json={"errors": [{"status": "404", "detail": "Not found."}]},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="different resource from regular scans"):
|
||||
await client.call_tool(
|
||||
"prowler_list_attack_paths_queries", {"scan_id": "s1"}
|
||||
)
|
||||
|
||||
|
||||
async def test_the_answer_names_the_tool_that_returns_a_usable_id(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""An explanation with no next step leaves the agent guessing IDs."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
QUERIES,
|
||||
status=404,
|
||||
json={"errors": [{"status": "404", "detail": "Not found."}]},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="prowler_list_attack_paths_scans"):
|
||||
await client.call_tool(
|
||||
"prowler_list_attack_paths_queries", {"scan_id": "s1"}
|
||||
)
|
||||
|
||||
|
||||
async def test_a_failure_that_is_not_a_404_keeps_the_shared_message(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Only the 404 means a bad ID. A 403 is a permission the ID cannot fix."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
QUERIES,
|
||||
status=403,
|
||||
json={"errors": [{"status": "403", "detail": "Denied."}]},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="prowler_get_current_user"):
|
||||
await client.call_tool(
|
||||
"prowler_list_attack_paths_queries", {"scan_id": "s1"}
|
||||
)
|
||||
|
||||
|
||||
async def test_queries_come_back_as_a_list(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The success path is unchanged."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
QUERIES,
|
||||
json=jsonapi_collection(
|
||||
[
|
||||
jsonapi_resource(
|
||||
"attack-paths-queries",
|
||||
"aws-ec2-instances-internet-exposed",
|
||||
{
|
||||
"name": "Internet exposed EC2",
|
||||
"description": "Find internet-exposed EC2 instances",
|
||||
"provider": "aws",
|
||||
"parameters": [],
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_list_attack_paths_queries", {"scan_id": "s1"}
|
||||
)
|
||||
|
||||
assert result.data[0]["id"] == "aws-ec2-instances-internet-exposed"
|
||||
|
||||
|
||||
# ------------------------------------------------------------- running a query
|
||||
|
||||
RUN = "/api/v1/attack-paths-scans/s1/queries/run"
|
||||
SCHEMA = "/api/v1/attack-paths-scans/s1/schema"
|
||||
|
||||
EMPTY_RESULT = {
|
||||
"data": {
|
||||
"type": "attack-paths-query-results",
|
||||
"id": "s1",
|
||||
"attributes": {"nodes": [], "relationships": []},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _run_args(query_id: str = "aws-ec2-instances-internet-exposed") -> dict[str, str]:
|
||||
"""Arguments for a query run against the mocked scan."""
|
||||
return {"scan_id": "s1", "query_id": query_id}
|
||||
|
||||
|
||||
async def test_a_query_that_matched_nothing_is_an_answer_not_a_failure(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Prowler answers a query that matched nothing with 404 and the result body.
|
||||
|
||||
Raising on the status called a clean account a failed call and sent the agent
|
||||
off to re-check arguments that were right.
|
||||
"""
|
||||
mock_router.add("POST", RUN, status=404, json=EMPTY_RESULT)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_run_attack_paths_query", _run_args()
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
assert "matched nothing" in result.structuredContent["message"]
|
||||
|
||||
|
||||
async def test_an_empty_result_does_not_come_back_as_an_empty_object(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The serializer drops empty lists, so `{}` is all that would be left."""
|
||||
mock_router.add("POST", RUN, status=404, json=EMPTY_RESULT)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_run_attack_paths_query", _run_args())
|
||||
|
||||
assert result.data != {}
|
||||
|
||||
|
||||
async def test_a_null_graph_is_read_as_an_empty_one(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Prowler can spell the empty graph as `null` rather than as empty lists.
|
||||
|
||||
Reading `null` as if it were a graph crashed the parse, turning the same
|
||||
"nothing matched" answer into an error the agent could not act on.
|
||||
"""
|
||||
mock_router.add("POST", RUN, status=404, json={"data": {"attributes": None}})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_run_attack_paths_query", _run_args()
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
assert "matched nothing" in result.structuredContent["message"]
|
||||
|
||||
|
||||
async def test_a_run_against_an_unknown_scan_still_names_the_confusion(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A 404 with no result body is the ID being wrong, not an empty answer."""
|
||||
mock_router.add(
|
||||
"POST",
|
||||
RUN,
|
||||
status=404,
|
||||
json={"errors": [{"status": "404", "detail": "Not found."}]},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="different resource from regular scans"):
|
||||
await client.call_tool("prowler_run_attack_paths_query", _run_args())
|
||||
|
||||
|
||||
async def test_a_scan_whose_graph_records_no_schema_says_so(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""This 404 is about the graph, not the ID, so it must not blame the ID."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
SCHEMA,
|
||||
status=404,
|
||||
json={"detail": "No cartography schema metadata found for this provider"},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="no Cartography schema recorded"):
|
||||
await client.call_tool(
|
||||
"prowler_get_attack_paths_cartography_schema", {"scan_id": "s1"}
|
||||
)
|
||||
|
||||
|
||||
async def test_a_schema_request_for_an_unknown_scan_names_the_confusion(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The other 404 here is the ID, and Prowler writes a JSON:API error for it."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
SCHEMA,
|
||||
status=404,
|
||||
json={"errors": [{"status": "404", "detail": "Not found."}]},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="different resource from regular scans"):
|
||||
await client.call_tool(
|
||||
"prowler_get_attack_paths_cartography_schema", {"scan_id": "s1"}
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for the compliance tools.
|
||||
|
||||
Both compliance tools answer for exactly one scan. ``scan_id`` names it
|
||||
directly; ``provider_id`` names it indirectly, as "the latest completed scan of
|
||||
this provider". Passing both is not a refinement of either -- the scan the
|
||||
caller named may belong to a different provider entirely -- so it is rejected
|
||||
rather than resolved by preferring one, which would answer confidently for a
|
||||
provider nobody asked about.
|
||||
|
||||
Tools are driven through an in-memory MCP client so FastMCP resolves the
|
||||
pydantic ``Field`` defaults and a raised failure arrives the way a client sees
|
||||
it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
|
||||
|
||||
SCANS = "/api/v1/scans"
|
||||
OVERVIEWS = "/api/v1/compliance-overviews"
|
||||
REQUIREMENTS = f"{OVERVIEWS}/requirements"
|
||||
|
||||
TOOLS = [
|
||||
"prowler_get_compliance_overview",
|
||||
"prowler_get_compliance_framework_state_details",
|
||||
]
|
||||
|
||||
|
||||
def arguments(tool: str, **overrides) -> dict:
|
||||
"""Build the arguments for either tool, which differ only in compliance_id."""
|
||||
payload = dict(overrides)
|
||||
if tool.endswith("framework_state_details"):
|
||||
payload["compliance_id"] = "cis_1.5_aws"
|
||||
return payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", TOOLS)
|
||||
async def test_neither_a_scan_nor_a_provider_is_refused_before_any_request(
|
||||
mcp_root_server, mock_api_client, mock_router, tool
|
||||
):
|
||||
"""There is no scan to answer for, and no way to guess one."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="must be provided"):
|
||||
await client.call_tool(tool, arguments(tool))
|
||||
|
||||
assert mock_router.paths() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", TOOLS)
|
||||
async def test_a_scan_and_a_provider_together_are_refused_rather_than_reconciled(
|
||||
mcp_root_server, mock_api_client, mock_router, tool
|
||||
):
|
||||
"""Silently keeping the scan would answer for whichever provider owns it.
|
||||
|
||||
That report names a scan the caller did ask for, so nothing about it looks
|
||||
wrong -- while the provider they also named went unread.
|
||||
"""
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="not both"):
|
||||
await client.call_tool(
|
||||
tool, arguments(tool, scan_id="s1", provider_id="p1")
|
||||
)
|
||||
|
||||
assert mock_router.paths() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", TOOLS)
|
||||
async def test_a_provider_on_its_own_resolves_to_its_latest_completed_scan(
|
||||
mcp_root_server, mock_api_client, mock_router, tool
|
||||
):
|
||||
"""The indirection is the point of accepting a provider at all."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
SCANS,
|
||||
json=jsonapi_collection(
|
||||
[jsonapi_resource("scans", "s9", {"state": "completed"})]
|
||||
),
|
||||
)
|
||||
mock_router.add("GET", OVERVIEWS, json=jsonapi_collection([]))
|
||||
mock_router.add("GET", REQUIREMENTS, json=jsonapi_collection([]))
|
||||
# Each tool reads the compliance state from its own endpoint; both filter it
|
||||
# by the scan that had to be resolved first.
|
||||
read = OVERVIEWS if tool.endswith("overview") else REQUIREMENTS
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool(tool, arguments(tool, provider_id="p1"))
|
||||
|
||||
assert mock_router.query_params("GET", SCANS)["filter[provider]"] == "p1"
|
||||
assert mock_router.query_params("GET", read)["filter[scan_id]"] == "s9"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", TOOLS)
|
||||
async def test_a_provider_with_no_completed_scan_is_named_as_the_bad_argument(
|
||||
mcp_root_server, mock_api_client, mock_router, tool
|
||||
):
|
||||
"""Nothing has been scanned yet, so there is no compliance state to report."""
|
||||
mock_router.add("GET", SCANS, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="No completed scans found for provider p1"):
|
||||
await client.call_tool(tool, arguments(tool, provider_id="p1"))
|
||||
@@ -312,17 +312,16 @@ async def test_creating_a_jira_integration_rejects_an_empty_domain(
|
||||
):
|
||||
"""A domain that normalizes to nothing is caught before the round trip."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_create_jira_integration",
|
||||
{
|
||||
"domain": "https://",
|
||||
"user_mail": "security@acme.com",
|
||||
"api_token": "fake-atlassian-token-for-testing",
|
||||
},
|
||||
)
|
||||
with pytest.raises(Exception, match="Invalid Jira domain"):
|
||||
await client.call_tool(
|
||||
"prowler_create_jira_integration",
|
||||
{
|
||||
"domain": "https://",
|
||||
"user_mail": "security@acme.com",
|
||||
"api_token": "fake-atlassian-token-for-testing",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.data["status"] == "failed"
|
||||
assert "Invalid Jira domain" in result.data["error"]
|
||||
assert mock_router.requests == []
|
||||
|
||||
|
||||
@@ -334,14 +333,10 @@ async def test_creating_a_jira_integration_rejects_an_empty_domain(
|
||||
],
|
||||
ids=["security-hub", "amazon-s3"],
|
||||
)
|
||||
async def test_a_rejected_creation_is_reported_rather_than_raised(
|
||||
async def test_a_rejected_creation_fails_with_the_api_reason(
|
||||
mcp_root_server, mock_api_client, mock_router, tool, arguments
|
||||
):
|
||||
"""Write tools answer with an error object so the agent can act on it.
|
||||
|
||||
A raised exception reaches the model as a tool failure with no detail, and
|
||||
the API's message is exactly what tells it what to do next.
|
||||
"""
|
||||
"""A refused creation is a tool error, and it still carries the API's reason."""
|
||||
mock_router.add(
|
||||
"POST",
|
||||
INTEGRATIONS,
|
||||
@@ -350,10 +345,8 @@ async def test_a_rejected_creation_is_reported_rather_than_raised(
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(tool, arguments)
|
||||
|
||||
assert result.data["status"] == "failed"
|
||||
assert "already has this integration" in result.data["error"]
|
||||
with pytest.raises(Exception, match="already has this integration"):
|
||||
await client.call_tool(tool, arguments)
|
||||
|
||||
|
||||
async def test_a_creation_with_no_id_back_warns_before_a_blind_retry(
|
||||
@@ -367,12 +360,11 @@ async def test_a_creation_with_no_id_back_warns_before_a_blind_retry(
|
||||
mock_router.add("POST", INTEGRATIONS, json={"data": {}})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"}
|
||||
)
|
||||
with pytest.raises(Exception, match="did not return its ID"):
|
||||
await client.call_tool(
|
||||
"prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"}
|
||||
)
|
||||
|
||||
assert result.data["status"] == "failed"
|
||||
assert "did not return its ID" in result.data["error"]
|
||||
assert mock_router.paths() == [f"POST {INTEGRATIONS}"]
|
||||
|
||||
|
||||
@@ -395,12 +387,10 @@ async def test_a_creation_whose_read_back_fails_still_hands_over_the_id(
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"}
|
||||
)
|
||||
|
||||
assert result.data["status"] == "failed"
|
||||
assert "Integration i1 was created" in result.data["error"]
|
||||
with pytest.raises(Exception, match="Integration i1 was created"):
|
||||
await client.call_tool(
|
||||
"prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"}
|
||||
)
|
||||
|
||||
|
||||
async def test_a_connection_check_that_cannot_run_is_not_reported_as_a_failure(
|
||||
@@ -542,13 +532,12 @@ async def test_a_configuration_that_is_not_an_object_is_rejected_before_the_writ
|
||||
stub_integration(mock_router, S3_ATTRIBUTES)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "configuration": configuration},
|
||||
)
|
||||
with pytest.raises(Exception, match=message):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "configuration": configuration},
|
||||
)
|
||||
|
||||
assert result.data["status"] == "failed"
|
||||
assert message in result.data["error"]
|
||||
assert f"PATCH {INTEGRATION}" not in mock_router.paths()
|
||||
|
||||
|
||||
@@ -643,13 +632,12 @@ async def test_updating_a_jira_configuration_is_refused(
|
||||
stub_integration(mock_router, JIRA_ATTRIBUTES)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "configuration": {"domain": "other"}},
|
||||
)
|
||||
with pytest.raises(Exception, match="do not accept a configuration"):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "configuration": {"domain": "other"}},
|
||||
)
|
||||
|
||||
assert result.data["status"] == "failed"
|
||||
assert "do not accept a configuration" in result.data["error"]
|
||||
assert f"PATCH {INTEGRATION}" not in mock_router.paths()
|
||||
|
||||
|
||||
@@ -660,12 +648,12 @@ async def test_attaching_a_jira_integration_to_a_provider_is_refused(
|
||||
stub_integration(mock_router, JIRA_ATTRIBUTES)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "provider_ids": ["p1"]},
|
||||
)
|
||||
with pytest.raises(Exception, match="tenant-wide"):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "provider_ids": ["p1"]},
|
||||
)
|
||||
|
||||
assert "tenant-wide" in result.data["error"]
|
||||
assert f"PATCH {INTEGRATION}" not in mock_router.paths()
|
||||
|
||||
|
||||
@@ -683,12 +671,12 @@ async def test_security_hub_must_keep_exactly_one_provider(
|
||||
stub_integration(mock_router, SECURITY_HUB_ATTRIBUTES, provider_ids=("p1",))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "provider_ids": provider_ids},
|
||||
)
|
||||
with pytest.raises(Exception, match="exactly one AWS provider"):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "provider_ids": provider_ids},
|
||||
)
|
||||
|
||||
assert "exactly one AWS provider" in result.data["error"]
|
||||
assert f"PATCH {INTEGRATION}" not in mock_router.paths()
|
||||
|
||||
|
||||
@@ -708,12 +696,12 @@ async def test_partial_jira_credentials_are_refused_to_protect_the_stored_ones(
|
||||
stub_integration(mock_router, JIRA_ATTRIBUTES)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "credentials": credentials},
|
||||
)
|
||||
with pytest.raises(Exception, match="replaced as a whole"):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "credentials": credentials},
|
||||
)
|
||||
|
||||
assert "replaced as a whole" in result.data["error"]
|
||||
assert f"PATCH {INTEGRATION}" not in mock_router.paths()
|
||||
|
||||
|
||||
@@ -751,13 +739,14 @@ async def test_replacing_jira_credentials_normalizes_the_domain(
|
||||
# ------------------------------------------------- delete and connection tools
|
||||
|
||||
|
||||
async def test_deleting_an_integration_reports_the_outcome_either_way(
|
||||
async def test_deleting_an_integration_confirms_it_happened(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Deletion is irreversible, so both outcomes are stated explicitly.
|
||||
"""Deletion is irreversible, so a success says so rather than staying silent.
|
||||
|
||||
A bare exception would leave the agent unsure whether the credentials are
|
||||
gone, and a retry of a delete that actually succeeded reads as a new failure.
|
||||
It says so in the message and nowhere else: a `deleted: true` flag could only
|
||||
ever be true, because an integration that was not deleted leaves the tool as
|
||||
an error.
|
||||
"""
|
||||
mock_router.add("DELETE", INTEGRATION, status=204)
|
||||
|
||||
@@ -766,24 +755,23 @@ async def test_deleting_an_integration_reports_the_outcome_either_way(
|
||||
"prowler_delete_integration", {"integration_id": "i1"}
|
||||
)
|
||||
|
||||
assert result.data["deleted"] is True
|
||||
assert "i1 deleted successfully" in result.data["message"]
|
||||
assert "deleted" not in result.data
|
||||
|
||||
|
||||
async def test_a_failed_deletion_says_it_did_not_happen(
|
||||
async def test_a_refused_deletion_fails_and_says_the_role_is_the_problem(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""`deleted: false` is the part the agent must not have to infer."""
|
||||
"""A 403 is the same answer for every tool, so `lib.errors` writes it."""
|
||||
mock_router.add(
|
||||
"DELETE", INTEGRATION, status=403, json=jsonapi_error(403, "Permission denied.")
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_delete_integration", {"integration_id": "i1"}
|
||||
)
|
||||
|
||||
assert result.data["deleted"] is False
|
||||
assert "Permission denied." in result.data["message"]
|
||||
with pytest.raises(Exception, match="prowler_get_current_user"):
|
||||
await client.call_tool(
|
||||
"prowler_delete_integration", {"integration_id": "i1"}
|
||||
)
|
||||
|
||||
|
||||
async def test_checking_a_connection_surfaces_why_it_failed(
|
||||
@@ -1026,6 +1014,32 @@ async def test_an_accepted_dispatch_with_no_task_id_is_not_safe_to_retry(
|
||||
assert "task_id" not in result.data
|
||||
|
||||
|
||||
async def test_a_dispatch_with_no_usable_credential_is_raised_not_called_unknown(
|
||||
mcp_root_server, mock_api_client, mock_router, monkeypatch
|
||||
):
|
||||
"""Authentication runs before the request, so nothing was ever queued.
|
||||
|
||||
Reported as `unknown` it reads as "work items may exist in Jira, go and
|
||||
look" -- for a call that never reached Prowler. It is not a dispatch outcome
|
||||
at all: the credential has to be fixed, and no retry of this call does that.
|
||||
"""
|
||||
monkeypatch.setattr(mock_api_client.auth_manager, "mode", "http")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="no usable credential"):
|
||||
await client.call_tool(
|
||||
"prowler_send_findings_to_jira",
|
||||
{
|
||||
"integration_id": "i1",
|
||||
"project_key": "PROJ",
|
||||
"issue_type": "Task",
|
||||
"finding_ids": ["f1"],
|
||||
},
|
||||
)
|
||||
|
||||
assert mock_router.paths() == []
|
||||
|
||||
|
||||
async def test_a_dispatch_task_that_died_halfway_is_never_safe_to_retry(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for the muting tools.
|
||||
|
||||
Muting is permanent and deleting a rule does not undo it, so the one thing
|
||||
these assertions protect is that an agent is never told a deletion failed when
|
||||
it did not: the old answer keyed off the *shape* of the API's reply rather than
|
||||
off anything having gone wrong, and said nothing a caller could act on.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.jsonapi import jsonapi_document, jsonapi_error, jsonapi_resource
|
||||
|
||||
MUTE_RULE = "/api/v1/mute-rules/m1"
|
||||
|
||||
|
||||
async def test_a_deleted_rule_is_reported_deleted_whatever_the_body(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Prowler answers 204 with no body, but 200 with one is just as much a yes.
|
||||
|
||||
The old check read ``success`` out of the parsed body, which only exists for
|
||||
the empty-body case, so a deletion that worked could be reported as
|
||||
"Failed to delete mute rule".
|
||||
"""
|
||||
mock_router.add(
|
||||
"DELETE",
|
||||
MUTE_RULE,
|
||||
json=jsonapi_document(jsonapi_resource("mute-rules", "m1", {})),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_delete_mute_rule", {"rule_id": "m1"})
|
||||
|
||||
assert "m1 deleted successfully" in result.data["message"]
|
||||
assert "stay muted" in result.data["message"]
|
||||
# A flag with one reachable value is not a fact, it is an invitation to
|
||||
# branch on a shape that does not exist.
|
||||
assert "success" not in result.data
|
||||
|
||||
|
||||
async def test_an_empty_body_deletion_is_reported_the_same_way(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The 204 path, which is what Prowler actually sends today."""
|
||||
mock_router.add("DELETE", MUTE_RULE, status=204)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_delete_mute_rule", {"rule_id": "m1"})
|
||||
|
||||
assert "m1 deleted successfully" in result.data["message"]
|
||||
|
||||
|
||||
async def test_a_refused_deletion_is_an_error(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A rule that was not deleted has to leave the tool as an error."""
|
||||
mock_router.add(
|
||||
"DELETE", MUTE_RULE, status=404, json=jsonapi_error(404, "Not found.")
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="Not found"):
|
||||
await client.call_tool("prowler_delete_mute_rule", {"rule_id": "m1"})
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Tests for the provider tools.
|
||||
|
||||
Two behaviours drive most of the assertions here, and both are about telling a
|
||||
failure apart from an outcome that is merely not final yet:
|
||||
|
||||
* Deleting a provider removes it together with its scans, findings and
|
||||
resources, in a background task that routinely outlives the polling window.
|
||||
A deletion still running is not a failure, and reporting it as one invites a
|
||||
retry of a destructive call that is already in flight.
|
||||
* ``connect_provider`` runs a connection check, and a check that could not be
|
||||
run says nothing about the provider's credentials. Reporting it as ``failed``
|
||||
blames an AWS role for an expired Prowler credential and sends the user off to
|
||||
fix something that works.
|
||||
|
||||
Tools are driven through an in-memory MCP client so FastMCP resolves the
|
||||
pydantic ``Field`` defaults and a raised failure arrives the way a client sees
|
||||
it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.http import MockRouter
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_collection,
|
||||
jsonapi_document,
|
||||
jsonapi_error,
|
||||
jsonapi_resource,
|
||||
task_document,
|
||||
)
|
||||
|
||||
PROVIDERS = "/api/v1/providers"
|
||||
PROVIDER = f"{PROVIDERS}/p1"
|
||||
CONNECTION = f"{PROVIDER}/connection"
|
||||
SECRETS = f"{PROVIDERS}/secrets"
|
||||
TASK = "/api/v1/tasks/t1"
|
||||
|
||||
PROVIDER_ATTRIBUTES = {
|
||||
"uid": "123456789012",
|
||||
"provider": "aws",
|
||||
"alias": "production",
|
||||
"connection": {"connected": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_fast_polling(monkeypatch, api_client):
|
||||
"""Run the real polling loop, with a timeout a test can afford to wait out.
|
||||
|
||||
The timeout path is the one worth testing here -- it is what used to be
|
||||
reported as a failed deletion -- so the loop, its exception and the fallback
|
||||
that reads the task afterwards all stay real. Only the 60 seconds go.
|
||||
"""
|
||||
original = type(api_client).poll_task_until_complete
|
||||
|
||||
async def _fast(self, task_id, **_overridden):
|
||||
return await original(self, task_id, timeout=0.3, poll_interval=0.05)
|
||||
|
||||
monkeypatch.setattr(type(api_client), "poll_task_until_complete", _fast)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_polling_timeout(monkeypatch, api_client):
|
||||
"""Make the polling window run out on the first call, without the wait.
|
||||
|
||||
The clock is what ends the polling loop here, so a test about *what happens
|
||||
afterwards* has no reason to spend it. The read the fallback then makes is
|
||||
the real one.
|
||||
"""
|
||||
|
||||
async def _timeout(self, task_id, **_overridden):
|
||||
raise TimeoutError(f"Task {task_id} polling timed out after 60 seconds.")
|
||||
|
||||
monkeypatch.setattr(type(api_client), "poll_task_until_complete", _timeout)
|
||||
|
||||
|
||||
def stub_deletion_start(mock_router: MockRouter) -> MockRouter:
|
||||
"""Serve the DELETE as Prowler does: a task to poll, not a finished deletion."""
|
||||
return mock_router.add(
|
||||
"DELETE", PROVIDER, json=jsonapi_document(jsonapi_resource("tasks", "t1", {}))
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- deletion
|
||||
|
||||
|
||||
async def test_a_refused_deletion_is_an_error_not_a_result(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Nothing started, so the classifier owns the message.
|
||||
|
||||
Returned as ``{"deleted": false}`` it arrives with ``isError: false`` and a
|
||||
model has no reason to treat it as anything but a completed call.
|
||||
"""
|
||||
mock_router.add(
|
||||
"DELETE", PROVIDER, status=403, json=jsonapi_error(403, "Not allowed.")
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="not allowed to do this"):
|
||||
await client.call_tool("prowler_delete_provider", {"provider_id": "p1"})
|
||||
|
||||
|
||||
async def test_a_deletion_with_no_task_back_warns_before_a_blind_retry(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Prowler accepted it, so the deletion is probably running.
|
||||
|
||||
Without the task ID there is nothing to watch it with, which makes "check
|
||||
whether it is gone" the only safe instruction.
|
||||
"""
|
||||
mock_router.add("DELETE", PROVIDER, json={"data": {}})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="did not return the ID"):
|
||||
await client.call_tool("prowler_delete_provider", {"provider_id": "p1"})
|
||||
|
||||
assert mock_router.paths() == [f"DELETE {PROVIDER}"]
|
||||
|
||||
|
||||
async def test_a_finished_deletion_reports_it_plainly(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The success path is unchanged: the provider is gone."""
|
||||
stub_deletion_start(mock_router)
|
||||
mock_router.add("GET", TASK, json=task_document("t1", "completed"))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_delete_provider", {"provider_id": "p1"}
|
||||
)
|
||||
|
||||
assert result.data["status"] == "deleted"
|
||||
|
||||
|
||||
async def test_a_deletion_still_running_is_not_reported_as_a_failure(
|
||||
mcp_root_server, mock_api_client, mock_router, mock_fast_polling
|
||||
):
|
||||
"""Outliving the polling window is normal for a provider with many findings.
|
||||
|
||||
The task ID goes back so the deletion can be followed, and the message says
|
||||
not to send it again -- which is the whole point of not calling this failed.
|
||||
"""
|
||||
stub_deletion_start(mock_router)
|
||||
mock_router.add("GET", TASK, json=task_document("t1", "executing"))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_delete_provider", {"provider_id": "p1"}
|
||||
)
|
||||
|
||||
assert result.data["status"] == "in_progress"
|
||||
assert result.data["task_id"] == "t1"
|
||||
assert "Do not send the deletion again" in result.data["message"]
|
||||
|
||||
|
||||
async def test_a_deletion_that_finished_just_after_the_wait_is_reported_as_deleted(
|
||||
mcp_root_server, mock_api_client, mock_router, mock_polling_timeout
|
||||
):
|
||||
"""Polling gives up on the clock, not on the task.
|
||||
|
||||
A deletion that completed a moment after the last poll is a finished
|
||||
deletion, and the read the fallback makes is what says so. Reporting it as
|
||||
still running would send the caller off to watch a provider that is gone.
|
||||
"""
|
||||
stub_deletion_start(mock_router)
|
||||
mock_router.add("GET", TASK, json=task_document("t1", "completed"))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_delete_provider", {"provider_id": "p1"}
|
||||
)
|
||||
|
||||
assert result.data["status"] == "deleted"
|
||||
assert "task_id" not in result.data
|
||||
|
||||
|
||||
async def test_a_deletion_task_that_stopped_is_an_error_naming_what_is_left(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Here the provider really is still there, so this one is a failure.
|
||||
|
||||
A provider is removed together with everything attached to it, so a task
|
||||
that stopped halfway can leave part of that gone -- which is why the message
|
||||
sends the caller to look rather than asserting the state.
|
||||
"""
|
||||
stub_deletion_start(mock_router)
|
||||
mock_router.add("GET", TASK, json=task_document("t1", "failed", error="boom"))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="ended as 'failed'"):
|
||||
await client.call_tool("prowler_delete_provider", {"provider_id": "p1"})
|
||||
|
||||
|
||||
async def test_a_deletion_task_failure_does_not_relay_the_upstream_text(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The task's own error is a celery traceback; it stays in the log."""
|
||||
stub_deletion_start(mock_router)
|
||||
mock_router.add(
|
||||
"GET",
|
||||
TASK,
|
||||
json=task_document("t1", "failed", error="Traceback: secret-internal-detail"),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception) as raised:
|
||||
await client.call_tool("prowler_delete_provider", {"provider_id": "p1"})
|
||||
|
||||
assert "secret-internal-detail" not in str(raised.value)
|
||||
|
||||
|
||||
async def test_a_deletion_whose_progress_cannot_be_read_still_says_do_not_retry(
|
||||
mcp_root_server, mock_api_client, mock_router, mock_fast_polling
|
||||
):
|
||||
"""The outcome is unknown, which for a destructive call means: do not repeat.
|
||||
|
||||
Reading the task is what tells "still running" from "stopped", so when that
|
||||
read fails too the message drops the claim rather than guessing at one.
|
||||
"""
|
||||
stub_deletion_start(mock_router)
|
||||
mock_router.add("GET", TASK, status=503, json=jsonapi_error(503, "Unavailable."))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_delete_provider", {"provider_id": "p1"}
|
||||
)
|
||||
|
||||
assert result.data["status"] == "in_progress"
|
||||
assert "could not be read" in result.data["message"]
|
||||
assert "Do not send the deletion again" in result.data["message"]
|
||||
# The failure that got us here is the classifier's to phrase, so its raw
|
||||
# text stays in the log rather than riding along in the message.
|
||||
assert "API request failed" not in result.data["message"]
|
||||
|
||||
|
||||
# ------------------------------------------------------- connection check
|
||||
|
||||
|
||||
async def test_a_connection_check_that_cannot_run_is_not_reported_as_failed(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""`not_tested` says nothing about the credentials, and that is the point.
|
||||
|
||||
A 401 here is this server's own credential, not the provider's. Calling it
|
||||
`failed` tells the user their AWS role is broken when it is fine.
|
||||
"""
|
||||
# Registered in order and consumed in order: the lookup before the create
|
||||
# finds nothing, the one after it finds the provider that was just made.
|
||||
mock_router.add("GET", PROVIDERS, json=jsonapi_collection([]))
|
||||
mock_router.add(
|
||||
"GET",
|
||||
PROVIDERS,
|
||||
json=jsonapi_collection(
|
||||
[jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)]
|
||||
),
|
||||
)
|
||||
mock_router.add(
|
||||
"POST",
|
||||
PROVIDERS,
|
||||
json=jsonapi_document(jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)),
|
||||
)
|
||||
mock_router.add(
|
||||
"POST", CONNECTION, status=401, json=jsonapi_error(401, "Token expired.")
|
||||
)
|
||||
mock_router.add(
|
||||
"GET",
|
||||
PROVIDER,
|
||||
json=jsonapi_document(jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_connect_provider",
|
||||
{"provider_uid": "123456789012", "provider_type": "aws"},
|
||||
)
|
||||
|
||||
assert result.data["connected"] == "not_tested"
|
||||
assert "never tested" in result.data["error"]
|
||||
|
||||
|
||||
async def test_a_secret_lookup_failure_does_not_pass_as_having_no_secret(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Returning None here would send the write down the create branch.
|
||||
|
||||
A provider holds at most one secret, so creating a second one is refused and
|
||||
the caller would be told its credentials were rejected when all that
|
||||
actually failed was this read.
|
||||
"""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
PROVIDERS,
|
||||
json=jsonapi_collection(
|
||||
[jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)]
|
||||
),
|
||||
)
|
||||
mock_router.add(
|
||||
"GET", SECRETS, status=429, json=jsonapi_error(429, "Too many requests.")
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="rate limiting"):
|
||||
await client.call_tool(
|
||||
"prowler_connect_provider",
|
||||
{
|
||||
"provider_uid": "123456789012",
|
||||
"provider_type": "aws",
|
||||
"credentials": {
|
||||
"aws_access_key_id": "AKIA",
|
||||
"aws_secret_access_key": "s",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert f"POST {SECRETS}" not in mock_router.paths()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests for the role (RBAC) tools.
|
||||
|
||||
``prowler_set_user_role`` replaces the single role a user holds, and the API
|
||||
silently drops a role ID that does not exist in the tenant -- which would leave
|
||||
the user with no role at all. So the tool reads the role first, and what that
|
||||
read says has to be told apart carefully: only a not-found is about the role ID
|
||||
the caller passed. A permission error, a rate limit or a server error is about
|
||||
the request, and reporting either as "find a valid role ID" sends the user to
|
||||
fix an ID that was fine.
|
||||
|
||||
Tools are driven through an in-memory MCP client so FastMCP resolves the
|
||||
pydantic ``Field`` defaults and a raised failure arrives the way a client sees
|
||||
it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.http import MockRouter
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_document,
|
||||
jsonapi_error,
|
||||
jsonapi_resource,
|
||||
)
|
||||
|
||||
USER = "/api/v1/users/u1"
|
||||
USER_ROLES = f"{USER}/relationships/roles"
|
||||
ROLE = "/api/v1/roles/r2"
|
||||
|
||||
ROLE_ATTRIBUTES = {"name": "admin", "manage_account": True}
|
||||
|
||||
|
||||
def stub_user_holding(mock_router: MockRouter, role_id: str) -> MockRouter:
|
||||
"""Serve ``GET /users/u1?include=roles`` with the user holding one role."""
|
||||
return mock_router.add(
|
||||
"GET",
|
||||
USER,
|
||||
json=jsonapi_document(
|
||||
jsonapi_resource("users", "u1", {"name": "Ada"}),
|
||||
included=[jsonapi_resource("roles", role_id, ROLE_ATTRIBUTES)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_setting_a_role_the_user_already_holds_changes_nothing(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Idempotent by design: no PATCH goes out, so nothing can be replaced."""
|
||||
stub_user_holding(mock_router, "r2")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_set_user_role", {"user_id": "u1", "role_id": "r2"}
|
||||
)
|
||||
|
||||
assert result.data["changed"] is False
|
||||
assert mock_router.paths() == [f"GET {USER}"]
|
||||
|
||||
|
||||
async def test_a_role_that_does_not_exist_is_named_as_the_bad_argument(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""404 is the one answer that really is about the role ID.
|
||||
|
||||
The PATCH would accept the ID and drop it, leaving the user with no role, so
|
||||
the read has to stop the call -- and say which ID to replace.
|
||||
"""
|
||||
stub_user_holding(mock_router, "r1")
|
||||
mock_router.add("GET", ROLE, status=404, json=jsonapi_error(404, "Not found."))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="does not exist in this tenant") as raised:
|
||||
await client.call_tool(
|
||||
"prowler_set_user_role", {"user_id": "u1", "role_id": "r2"}
|
||||
)
|
||||
|
||||
assert "left unchanged" in str(raised.value)
|
||||
assert f"PATCH {USER_ROLES}" not in mock_router.paths()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "detail", "expected"),
|
||||
[
|
||||
(403, "Not allowed.", "not allowed to do this"),
|
||||
(429, "Slow down.", "rate limiting"),
|
||||
(500, "Boom.", "failed on Prowler's side"),
|
||||
],
|
||||
ids=["forbidden", "rate-limited", "server-error"],
|
||||
)
|
||||
async def test_a_role_read_that_failed_for_another_reason_is_not_a_bad_role_id(
|
||||
mcp_root_server, mock_api_client, mock_router, status, detail, expected
|
||||
):
|
||||
"""These say nothing about the ID, so the classifier owns the message.
|
||||
|
||||
Told "use prowler_list_roles to find a valid role ID", an agent goes looking
|
||||
for a role that was never the problem -- and finds the same wall.
|
||||
"""
|
||||
stub_user_holding(mock_router, "r1")
|
||||
mock_router.add("GET", ROLE, status=status, json=jsonapi_error(status, detail))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match=expected) as raised:
|
||||
await client.call_tool(
|
||||
"prowler_set_user_role", {"user_id": "u1", "role_id": "r2"}
|
||||
)
|
||||
|
||||
assert "valid role ID" not in str(raised.value)
|
||||
assert f"PATCH {USER_ROLES}" not in mock_router.paths()
|
||||
|
||||
|
||||
async def test_a_set_role_reports_the_role_the_user_holds_afterwards(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The authoritative state is read back rather than assumed from the PATCH."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
USER,
|
||||
json=jsonapi_document(
|
||||
jsonapi_resource("users", "u1", {"name": "Ada"}),
|
||||
included=[jsonapi_resource("roles", "r1", ROLE_ATTRIBUTES)],
|
||||
),
|
||||
)
|
||||
mock_router.add(
|
||||
"GET",
|
||||
USER,
|
||||
json=jsonapi_document(
|
||||
jsonapi_resource("users", "u1", {"name": "Ada"}),
|
||||
included=[jsonapi_resource("roles", "r2", ROLE_ATTRIBUTES)],
|
||||
),
|
||||
)
|
||||
mock_router.add(
|
||||
"GET",
|
||||
ROLE,
|
||||
json=jsonapi_document(jsonapi_resource("roles", "r2", ROLE_ATTRIBUTES)),
|
||||
)
|
||||
mock_router.add("PATCH", USER_ROLES, status=204)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_set_user_role", {"user_id": "u1", "role_id": "r2"}
|
||||
)
|
||||
|
||||
assert result.data["changed"] is True
|
||||
assert [role["id"] for role in result.data["roles"]] == ["r2"]
|
||||
assert mock_router.json_body("PATCH", USER_ROLES) == {
|
||||
"data": [{"type": "roles", "id": "r2"}]
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for the scans tools.
|
||||
|
||||
Both write tools here used to answer a failure with a result object that the
|
||||
protocol, the client and the model all read as a success, and both are calls
|
||||
whose outcome an agent acts on:
|
||||
|
||||
* ``prowler_trigger_scan`` starts work. Anything that reads as "nothing
|
||||
happened" invites a second scan of the same provider.
|
||||
* ``prowler_schedule_daily_scan`` was deciding whether the schedule existed by
|
||||
reading the state of a different thing entirely -- the first scan Prowler
|
||||
starts alongside it -- so a schedule that had just been created could be
|
||||
reported as a failure. Retrying that can only hit the 409 the API raises for a
|
||||
provider that already has one.
|
||||
|
||||
Tools are driven through an in-memory MCP client so FastMCP resolves the
|
||||
pydantic ``Field`` defaults and a raised failure arrives the way a client sees
|
||||
it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.http import MockRouter
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_document,
|
||||
jsonapi_error,
|
||||
jsonapi_resource,
|
||||
)
|
||||
|
||||
SCANS = "/api/v1/scans"
|
||||
SCAN = f"{SCANS}/s1"
|
||||
DAILY = "/api/v1/schedules/daily"
|
||||
|
||||
SCAN_ATTRIBUTES = {
|
||||
"name": "Nightly",
|
||||
"trigger": "manual",
|
||||
"state": "executing",
|
||||
"progress": 40,
|
||||
}
|
||||
|
||||
|
||||
def stub_scan_creation(mock_router: MockRouter, scan_id: str = "s1") -> MockRouter:
|
||||
"""Serve the creation as Prowler does: a task carrying the new scan's ID."""
|
||||
return mock_router.add(
|
||||
"POST",
|
||||
SCANS,
|
||||
json=jsonapi_document(
|
||||
jsonapi_resource("tasks", "t1", {"task_args": {"scan_id": scan_id}})
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- trigger_scan
|
||||
|
||||
|
||||
async def test_a_refused_scan_is_an_error_not_a_failed_looking_result(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A rejection has to leave the tool as an error.
|
||||
|
||||
Returned as a result it arrives with ``isError: false``, and a model reading
|
||||
a successful tool call has no reason to doubt that a scan is now running.
|
||||
"""
|
||||
mock_router.add(
|
||||
"POST", SCANS, status=403, json=jsonapi_error(403, "Insufficient permissions.")
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="not allowed to do this"):
|
||||
await client.call_tool("prowler_trigger_scan", {"provider_id": "p1"})
|
||||
|
||||
|
||||
async def test_a_scan_with_no_id_back_warns_before_a_blind_retry(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Prowler accepted it, so a second call could start a duplicate scan.
|
||||
|
||||
The message names the provider because that is what makes the suggested
|
||||
check actionable without another lookup.
|
||||
"""
|
||||
mock_router.add("POST", SCANS, json={"data": {"attributes": {"task_args": {}}}})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="did not return its ID"):
|
||||
await client.call_tool("prowler_trigger_scan", {"provider_id": "p1"})
|
||||
|
||||
assert mock_router.paths() == [f"POST {SCANS}"]
|
||||
|
||||
|
||||
async def test_a_scan_whose_read_back_fails_still_hands_over_the_id(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The scan is running; only reading it back went wrong.
|
||||
|
||||
Reporting the read failure alone would read as "the scan was not created"
|
||||
and invite a duplicate, so the error carries the ID to monitor instead.
|
||||
"""
|
||||
stub_scan_creation(mock_router)
|
||||
mock_router.add("GET", SCAN, status=400, json=jsonapi_error(400, "Server error."))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="Scan s1 was created") as raised:
|
||||
await client.call_tool("prowler_trigger_scan", {"provider_id": "p1"})
|
||||
|
||||
# Naming the scan is the whole reason this message exists, so it is written
|
||||
# here rather than assembled from the failure. Splicing the failure text in
|
||||
# would put whatever it happens to say in front of the model unclassified,
|
||||
# which is the one thing the shared classifier exists to decide.
|
||||
assert "API request failed" not in str(raised.value)
|
||||
|
||||
|
||||
async def test_a_created_scan_comes_back_with_its_details(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The success path still reports the scan, which is what gets monitored."""
|
||||
stub_scan_creation(mock_router)
|
||||
mock_router.add(
|
||||
"GET",
|
||||
SCAN,
|
||||
json=jsonapi_document(jsonapi_resource("scans", "s1", SCAN_ATTRIBUTES)),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_trigger_scan", {"provider_id": "p1"})
|
||||
|
||||
assert result.data["scan"]["id"] == "s1"
|
||||
# No status flag: a scan that was not created is raised, so "success" could
|
||||
# only ever be the one value and says nothing a reader can act on.
|
||||
assert "status" not in result.data
|
||||
|
||||
|
||||
# ------------------------------------------------------ schedule_daily_scan
|
||||
|
||||
|
||||
@pytest.mark.parametrize("first_run_state", ["available", "scheduled", "executing"])
|
||||
async def test_a_schedule_is_reported_created_whatever_the_first_run_does(
|
||||
mcp_root_server, mock_api_client, mock_router, first_run_state
|
||||
):
|
||||
"""The task in the answer is the first scan run, not the schedule.
|
||||
|
||||
Prowler commits the recurring schedule inside the request that serves this
|
||||
call, so an answer at all means it exists. Reading that task's state as the
|
||||
outcome of the scheduling reported a schedule that had just been created as
|
||||
a failure.
|
||||
"""
|
||||
mock_router.add(
|
||||
"POST",
|
||||
DAILY,
|
||||
json=jsonapi_document(
|
||||
jsonapi_resource("tasks", "t1", {"state": first_run_state})
|
||||
),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_schedule_daily_scan", {"provider_id": "p1"}
|
||||
)
|
||||
|
||||
assert result.data["first_run_state"] == first_run_state
|
||||
assert "every 24 hours" in result.data["message"]
|
||||
assert "scheduled" not in result.data
|
||||
|
||||
|
||||
async def test_a_failed_first_run_leaves_the_schedule_standing(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Worth saying, but it is not the schedule that failed.
|
||||
|
||||
The gap it leaves is real -- no findings until tomorrow -- so the message
|
||||
points at the manual scan that fills it rather than at the scheduling.
|
||||
"""
|
||||
mock_router.add(
|
||||
"POST",
|
||||
DAILY,
|
||||
json=jsonapi_document(jsonapi_resource("tasks", "t1", {"state": "failed"})),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_schedule_daily_scan", {"provider_id": "p1"}
|
||||
)
|
||||
|
||||
assert result.data["first_run_state"] == "failed"
|
||||
assert "schedule is unaffected" in result.data["message"]
|
||||
assert "prowler_trigger_scan" in result.data["message"]
|
||||
|
||||
|
||||
async def test_an_existing_schedule_is_relayed_as_the_api_explains_it(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The 409 already says the useful thing, so the classifier relays it."""
|
||||
mock_router.add(
|
||||
"POST",
|
||||
DAILY,
|
||||
status=409,
|
||||
json=jsonapi_error(409, "There is already a scheduled scan for this provider."),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="already a scheduled scan"):
|
||||
await client.call_tool("prowler_schedule_daily_scan", {"provider_id": "p1"})
|
||||
@@ -6,8 +6,12 @@ Reference for later branches: ``ProwlerAppAuth`` resolves its ``mode`` and
|
||||
and ``base_url=`` explicitly, as these tests do.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler_mcp_server.lib.errors import CredentialError
|
||||
from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
|
||||
from tests.helpers.tokens import FAKE_API_KEY, MALFORMED_API_KEY, fake_jwt
|
||||
|
||||
@@ -42,13 +46,92 @@ async def test_http_mode_accepts_a_bearer_api_key(http_request_headers):
|
||||
assert await auth.get_valid_token() == FAKE_API_KEY
|
||||
|
||||
|
||||
def _jwt_with_payload(payload: object) -> str:
|
||||
"""Mint an unsigned JWT carrying an arbitrary payload.
|
||||
|
||||
``fake_jwt`` always writes a well-formed object, so the malformed payloads
|
||||
below are built here instead.
|
||||
"""
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
||||
)
|
||||
return f"header.{encoded}.fake-signature-not-verified"
|
||||
|
||||
|
||||
async def test_http_mode_accepts_a_lowercase_bearer_scheme(http_request_headers):
|
||||
"""Authentication scheme names are case-insensitive (RFC 7235)."""
|
||||
http_request_headers(authorization=f"bearer {FAKE_API_KEY}")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
assert await auth.get_valid_token() == FAKE_API_KEY
|
||||
|
||||
|
||||
async def test_http_mode_strips_only_the_scheme_prefix(http_request_headers):
|
||||
"""A token that repeats the scheme keeps it: only the prefix is removed."""
|
||||
token = f"{FAKE_API_KEY}_Bearer_suffix"
|
||||
http_request_headers(authorization=f"Bearer {token}")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
assert await auth.get_valid_token() == token
|
||||
|
||||
|
||||
async def test_http_mode_rejects_an_authorization_header_without_a_token(
|
||||
http_request_headers,
|
||||
):
|
||||
"""A bare scheme carries no credential to authenticate with."""
|
||||
http_request_headers(authorization="Bearer ")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
with pytest.raises(CredentialError, match="'Bearer <token>' form"):
|
||||
await auth.get_valid_token()
|
||||
|
||||
|
||||
async def test_http_mode_rejects_a_jwt_whose_payload_is_not_an_object(
|
||||
http_request_headers,
|
||||
):
|
||||
"""A payload that decodes to a list has no claims, so it is a bad credential.
|
||||
|
||||
Without the type check it would reach `payload.get` and fail as an
|
||||
unclassified `AttributeError`, which the client only sees masked.
|
||||
"""
|
||||
http_request_headers(authorization=f"Bearer {_jwt_with_payload(['exp'])}")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
with pytest.raises(CredentialError, match="not a readable JWT"):
|
||||
await auth.get_valid_token()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "case"),
|
||||
[
|
||||
({"sub": "user"}, "missing"),
|
||||
({"exp": "1700000000"}, "string"),
|
||||
({"exp": None}, "null"),
|
||||
],
|
||||
)
|
||||
async def test_http_mode_rejects_a_jwt_without_a_numeric_expiration(
|
||||
http_request_headers, payload: dict, case: str
|
||||
):
|
||||
"""`exp` is a numeric date: comparing anything else raises a `TypeError`."""
|
||||
http_request_headers(authorization=f"Bearer {_jwt_with_payload(payload)}")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
with pytest.raises(CredentialError, match="no readable 'exp' expiration claim"):
|
||||
await auth.get_valid_token()
|
||||
|
||||
|
||||
async def test_http_mode_rejects_an_expired_jwt(http_request_headers):
|
||||
"""An expired JWT is refused locally instead of being forwarded to the API."""
|
||||
http_request_headers(authorization=f"Bearer {fake_jwt(expires_in=-60)}")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
with pytest.raises(ValueError, match="Token has expired"):
|
||||
with pytest.raises(CredentialError, match="The token has expired"):
|
||||
await auth.get_valid_token()
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Tests for the Prowler documentation search tool.
|
||||
"""Tests for the Prowler documentation tools.
|
||||
|
||||
Mintlify moved the docs search to a new endpoint that answers with page
|
||||
sections, so a result is a part of a page and has to read as one.
|
||||
sections, so a result is a part of a page and has to read as one. And a failed
|
||||
request must not reach an agent as "the documentation has nothing on this",
|
||||
which is an answer it would act on, confidently and wrongly.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -9,6 +11,7 @@ import json
|
||||
from fastmcp import Client
|
||||
|
||||
SEARCH = "/api/search/prowler"
|
||||
DOC = "/getting-started/installation.md"
|
||||
|
||||
|
||||
def search_match(
|
||||
@@ -107,3 +110,65 @@ async def test_page_size_caps_a_response_the_api_did_not_size(
|
||||
)
|
||||
|
||||
assert len(result.data) == 2
|
||||
|
||||
|
||||
async def test_a_search_that_failed_is_not_reported_as_no_matches(
|
||||
mcp_root_server, docs_router
|
||||
):
|
||||
"""An empty list is an answer. A failed request is not, and must not look like one."""
|
||||
docs_router.add("POST", SEARCH, status=500, text="upstream error")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_docs_search", {"term": "install"})
|
||||
|
||||
assert result.isError is True
|
||||
assert result.structuredContent is None
|
||||
|
||||
|
||||
async def test_a_missing_page_fails_and_names_the_tool_that_finds_a_valid_path(
|
||||
mcp_root_server, docs_router
|
||||
):
|
||||
"""A 404 answers the question, and still reaches the agent as an error."""
|
||||
docs_router.add("GET", DOC, status=404, text="Not Found")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_docs_get_document", {"doc_path": "getting-started/installation"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "prowler_docs_search" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_fetch_that_failed_is_not_reported_as_a_missing_page(
|
||||
mcp_root_server, docs_router
|
||||
):
|
||||
"""Only a 404 answers the question; every other status left it unanswered."""
|
||||
docs_router.add("GET", DOC, status=503, text="upstream error")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_docs_get_document", {"doc_path": "getting-started/installation"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "no page at" not in result.content[0].text
|
||||
|
||||
|
||||
async def test_an_unreadable_body_is_not_reported_as_a_bad_search_term(
|
||||
mcp_root_server, docs_router
|
||||
):
|
||||
"""An edge serving HTML is the site's fault; the caller has no term to fix."""
|
||||
docs_router.add("POST", SEARCH, status=200, text="<html>edge error page</html>")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_docs_search", {"term": "install"})
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
# Named as the upstream at fault, and explicitly not the caller's arguments,
|
||||
# which is the story the shared ValueError branch would otherwise tell.
|
||||
assert "leaves.mintlify.com" in message
|
||||
assert "changing them will not help" in message
|
||||
# The body it choked on is upstream text, which this server never relays.
|
||||
assert "edge error page" not in message
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Tests for the Prowler Hub tools.
|
||||
|
||||
The Hub sub-server uses its own httpx clients, so its failures never pass through
|
||||
the Prowler API client. They still have to arrive as tool errors rather than as a
|
||||
result object, which the protocol, the client and the model all read as a success.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
CHECKS = "/api/check"
|
||||
PROVIDERS = "/api/providers"
|
||||
COMPLIANCE = "/api/compliance"
|
||||
CHECK_ID = "s3_bucket_public_access"
|
||||
HUB_CHECK = f"{CHECKS}/{CHECK_ID}"
|
||||
|
||||
|
||||
def github_check(provider: str, suffix: str = ".py") -> str:
|
||||
"""The raw.githubusercontent path a check artifact is fetched from."""
|
||||
return (
|
||||
f"/prowler-cloud/prowler/refs/heads/master/prowler/providers/{provider}"
|
||||
f"/services/s3/{CHECK_ID}/{CHECK_ID}{suffix}"
|
||||
)
|
||||
|
||||
|
||||
GITHUB_CHECK = github_check("aws")
|
||||
GITHUB_FIXER = github_check("aws", "_fixer.py")
|
||||
|
||||
|
||||
async def test_listing_checks_returns_the_lightweight_shape(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The happy path, so the failure tests below are not the only coverage."""
|
||||
hub_router.add(
|
||||
"GET",
|
||||
CHECKS,
|
||||
json=[
|
||||
{
|
||||
"id": "s3_bucket_public_access",
|
||||
"provider": "aws",
|
||||
"title": "S3 buckets should block public access",
|
||||
"severity": "high",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_hub_list_checks", {})
|
||||
|
||||
assert result.data["count"] == 1
|
||||
assert result.data["checks"][0]["id"] == "s3_bucket_public_access"
|
||||
|
||||
|
||||
async def test_an_unknown_check_fails_and_names_the_tool_that_finds_one(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""A 404 is the caller's mistake, and the fix is a different tool."""
|
||||
hub_router.add("GET", f"{CHECKS}/nope", status=404)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_details", {"check_id": "nope"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "prowler_hub_semantic_search_checks" in result.content[0].text
|
||||
|
||||
|
||||
async def test_an_unknown_provider_fails_and_lists_the_real_ones(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The valid values are already in hand, so withholding them wastes a call."""
|
||||
hub_router.add(
|
||||
"GET",
|
||||
PROVIDERS,
|
||||
json=[{"id": "aws", "name": "Amazon Web Services", "services": ["s3"]}],
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_provider_services", {"provider_id": "alicloud"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "aws" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_check_without_a_fixer_says_that_is_normal(mcp_root_server, hub_router):
|
||||
"""Most checks have no auto-remediation, so this must not read as a defect."""
|
||||
hub_router.add("GET", GITHUB_FIXER, status=404)
|
||||
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_fixer",
|
||||
{"provider_id": "aws", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "normal" in message
|
||||
# The Hub confirmed the check is an aws check, so nothing is left to verify.
|
||||
assert "prowler_hub_get_check_details" not in message
|
||||
|
||||
|
||||
async def test_a_check_from_another_provider_names_the_provider_that_has_it(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The ID exists; only the provider is wrong. Saying otherwise sends the
|
||||
caller off to search for an ID they already hold."""
|
||||
hub_router.add("GET", github_check("azure"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "provider_id='aws'" in message
|
||||
assert "No check with the ID" not in message
|
||||
|
||||
|
||||
async def test_a_fixer_from_another_provider_is_not_reported_as_a_missing_fixer(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""'That check has no fixer' about a check the provider never had is a lie
|
||||
the caller cannot act on."""
|
||||
hub_router.add("GET", github_check("azure", "_fixer.py"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_fixer",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "provider_id='aws'" in message
|
||||
assert "auto-remediation" not in message
|
||||
|
||||
|
||||
async def test_a_check_id_that_exists_nowhere_is_still_reported_as_unknown(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The Hub not having the ID either is the one case that does justify the
|
||||
original message."""
|
||||
hub_router.add("GET", github_check("azure"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, status=404)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "No check with the ID" in result.content[0].text
|
||||
|
||||
|
||||
async def test_an_unreachable_hub_leaves_the_cause_open_rather_than_guessing(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""With nothing to distinguish the causes, naming one of them is a guess."""
|
||||
hub_router.add("GET", github_check("azure"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, status=503)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "No check with the ID" not in message
|
||||
assert "prowler_hub_get_check_details" in message
|
||||
|
||||
|
||||
async def test_a_check_code_hit_never_asks_the_hub(mcp_root_server, hub_router):
|
||||
"""The Hub lookup exists to explain a 404. On the happy path it is dead
|
||||
weight -- a second round trip for every call that already succeeded."""
|
||||
hub_router.add("GET", GITHUB_CHECK, text="class s3_bucket_public_access: ...")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "aws", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert "class s3_bucket_public_access" in result.data["content"]
|
||||
assert hub_router.paths() == [f"GET {GITHUB_CHECK}"]
|
||||
|
||||
|
||||
async def test_a_hub_outage_is_reported_rather_than_returned_as_an_empty_list(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""An empty result set and a failed request are different answers."""
|
||||
hub_router.add("GET", CHECKS, status=500, json={"detail": "boom"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_hub_list_checks", {})
|
||||
|
||||
assert result.isError is True
|
||||
assert result.structuredContent is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("check_id", "routed_as", "sent_as"),
|
||||
[
|
||||
("../../evil", f"{CHECKS}/../../evil", b"/api/check/..%2F..%2Fevil"),
|
||||
("s3/../evil", f"{CHECKS}/s3/../evil", b"/api/check/s3%2F..%2Fevil"),
|
||||
("..", f"{CHECKS}/..", b"/api/check/%2E%2E"),
|
||||
(
|
||||
"s3_x?fields=all",
|
||||
f"{CHECKS}/s3_x?fields=all",
|
||||
b"/api/check/s3_x%3Ffields%3Dall",
|
||||
),
|
||||
("s3_x#frag", f"{CHECKS}/s3_x#frag", b"/api/check/s3_x%23frag"),
|
||||
],
|
||||
ids=["traversal", "mid-path", "dot-segment", "query", "fragment"],
|
||||
)
|
||||
async def test_an_id_names_a_check_and_cannot_name_an_endpoint(
|
||||
mcp_root_server, hub_router, check_id, routed_as, sent_as
|
||||
):
|
||||
"""The bug this pins: httpx resolved "../.." away and the request left
|
||||
/api/check for another endpoint of the Hub."""
|
||||
hub_router.add("GET", routed_as, status=404)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_details", {"check_id": check_id}
|
||||
)
|
||||
|
||||
assert hub_router.requests[0].url.raw_path == sent_as
|
||||
assert result.isError is True
|
||||
assert "No check with the ID" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_compliance_id_cannot_name_an_endpoint_either(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""Every Hub path is built by the same helper, so this holds without its own
|
||||
guard."""
|
||||
hub_router.add("GET", f"{COMPLIANCE}/../../evil", status=404)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_compliance_details", {"compliance_id": "../../evil"}
|
||||
)
|
||||
|
||||
assert hub_router.requests[0].url.raw_path == b"/api/compliance/..%2F..%2Fevil"
|
||||
assert result.isError is True
|
||||
assert "No compliance framework with the ID" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_check_source_url_confines_the_provider_and_the_check_alike(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""Both halves of the GitHub raw URL come from the caller, so both are
|
||||
confined."""
|
||||
hub_router.add("GET", github_check("../../../../evil"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "../../../../evil", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert (
|
||||
hub_router.requests[0].url.raw_path
|
||||
== github_check("..%2F..%2F..%2F..%2Fevil").encode()
|
||||
)
|
||||
assert result.isError is True
|
||||
|
||||
|
||||
async def test_a_hub_body_that_is_not_json_is_not_blamed_on_the_arguments(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""An edge answering 200 with an HTML page decodes to the same
|
||||
`JSONDecodeError` a malformed argument does, and the two mean opposite
|
||||
things: nothing in this call can be corrected."""
|
||||
hub_router.add("GET", CHECKS, text="<html><body>502 Bad Gateway</body></html>")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_hub_list_checks", {})
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "hub.prowler.com" in message
|
||||
assert "could not read as JSON" in message
|
||||
assert "Bad Gateway" not in message
|
||||
assert "Send it as a real object" not in message
|
||||
|
||||
|
||||
async def test_an_unreadable_hub_answer_does_not_become_an_unknown_check(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The 404 branch is the only one that may claim the ID does not exist. A
|
||||
body that could not be read says nothing about the ID."""
|
||||
hub_router.add("GET", HUB_CHECK, text="<html>not json</html>")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_details", {"check_id": CHECK_ID}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "could not read as JSON" in message
|
||||
assert "No check with the ID" not in message
|
||||
|
||||
|
||||
async def test_an_unreadable_hub_answer_leaves_a_missing_check_file_unexplained(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The Hub is asked which provider owns the check. A body it could not read
|
||||
is no more of an answer than an outage, so it hedges the same way."""
|
||||
hub_router.add("GET", github_check("azure"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, text="<html>not json</html>")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "No check with the ID" not in message
|
||||
assert "prowler_hub_get_check_details" in message
|
||||
@@ -4,6 +4,43 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [5.40.0] (Prowler v5.40.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- NCSC Cyber Essentials 3.3 compliance framework with Azure provider coverage across the five Cyber Essentials themes [(#11588)](https://github.com/prowler-cloud/prowler/pull/11588)
|
||||
- `oss_bucket_versioning_enabled` check for Alibaba Cloud provider, verifying that OSS buckets have versioning enabled to allow recovery from accidental or malicious object overwrite and deletion [(#11913)](https://github.com/prowler-cloud/prowler/pull/11913)
|
||||
- `defender_domain_dmarc_records_published` checks that every Exchange Online domain publishes a DMARC record with an enforcing policy (`p=quarantine` or `p=reject`) [(#11936)](https://github.com/prowler-cloud/prowler/pull/11936)
|
||||
- `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` [(#11943)](https://github.com/prowler-cloud/prowler/pull/11943)
|
||||
- `oss_bucket_server_side_encryption_enabled` check for Alibaba Cloud provider, verifying that OSS buckets have a default server-side encryption rule (AES256 or KMS) [(#11981)](https://github.com/prowler-cloud/prowler/pull/11981)
|
||||
- `organization_default_workflow_permissions_read_only` check for GitHub provider, verifying that organizations grant GitHub Actions workflows a read-only default `GITHUB_TOKEN` [(#12122)](https://github.com/prowler-cloud/prowler/pull/12122)
|
||||
- `ecr_repository_image_no_secrets` check for AWS provider, scanning the latest ECR repository image's configuration and filesystem layers for hardcoded secrets [(#12123)](https://github.com/prowler-cloud/prowler/pull/12123)
|
||||
- `repository_default_workflow_permissions_read_only` check for GitHub provider, verifying that repositories grant GitHub Actions workflows a read-only default `GITHUB_TOKEN` [(#12143)](https://github.com/prowler-cloud/prowler/pull/12143)
|
||||
- `vpc_security_group_open_egress` check for Huawei Cloud provider: VPC security groups do not allow open egress to the internet [(#12209)](https://github.com/prowler-cloud/prowler/pull/12209)
|
||||
- `organization_actions_pull_request_approval_disabled` check for GitHub provider, verifying that organizations prevent GitHub Actions from creating and approving pull requests [(#12394)](https://github.com/prowler-cloud/prowler/pull/12394)
|
||||
- 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 [(#12416)](https://github.com/prowler-cloud/prowler/pull/12416)
|
||||
- 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 [(#12416)](https://github.com/prowler-cloud/prowler/pull/12416)
|
||||
- `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. [(#12459)](https://github.com/prowler-cloud/prowler/pull/12459)
|
||||
- `Cluster` column in Kubernetes CIS, ISO27001, Prowler ThreatScore, and universal compliance outputs, populated with the resolved cluster name so multi-cluster scans can be told apart in the output [(#12506)](https://github.com/prowler-cloud/prowler/pull/12506)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Kubernetes `kubelet` checks no longer disappear from the scan with `TypeError: 'NoneType' object is not iterable` when a `kubelet-config` ConfigMap is broken: one with malformed YAML is logged and skipped while the valid ones are still evaluated, one without kubelet data is evaluated with an empty configuration instead of crashing the checks, and the `apiserver`, `controllermanager`, `etcd` and `scheduler` pod gatherers now always return a list [(#12225)](https://github.com/prowler-cloud/prowler/pull/12225)
|
||||
- IaC provider now raises typed `IacBaseException` errors (repository clone, Trivy missing, scan and output processing failures) instead of calling `sys.exit(1)`; the CLI still stops with the logged message, and API scans fail as regular task errors instead of a `SystemExit` escaping the worker [(#12227)](https://github.com/prowler-cloud/prowler/pull/12227)
|
||||
- CLI Slack integration (`--slack`) no longer fails when a scan produces no findings: the pass and fail percentages are guarded against a `findings_count` of 0, which previously raised `ZeroDivisionError` and sent `blocks=None` to Slack instead of the summary [(#12229)](https://github.com/prowler-cloud/prowler/pull/12229)
|
||||
- AWS FSBP compliance mapping for `IAM.9` and `EKS.1` referenced missing/renamed checks; both now point to their real, existing check IDs [(#12372)](https://github.com/prowler-cloud/prowler/pull/12372)
|
||||
- `ec2_securitygroup_not_used` no longer reports a false positive for security groups attached only to an AWS Batch compute environment, which holds them in configuration without creating a network interface while scaled down to zero instances [(#12458)](https://github.com/prowler-cloud/prowler/pull/12458)
|
||||
- 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. [(#12459)](https://github.com/prowler-cloud/prowler/pull/12459)
|
||||
- `push-to-cloud` now validates Private Cloud TLS certificates with the operating system trust store without changing provider HTTP clients [(#12485)](https://github.com/prowler-cloud/prowler/pull/12485)
|
||||
- `prowler.compliance.universal` entry point directories are resolved through a single shared helper and deduplicated by resolved path, so a directory reached through two entry points is parsed once and a package that fails to import no longer hides the rest [(#12536)](https://github.com/prowler-cloud/prowler/pull/12536)
|
||||
- OSS bucket logging, versioning, default encryption and ACL configurations are now read correctly from the Alibaba Cloud SDK, so `oss_bucket_logging_enabled`, `oss_bucket_versioning_enabled`, `oss_bucket_server_side_encryption_enabled` and `oss_bucket_not_publicly_accessible` no longer report every bucket as unconfigured [(#12546)](https://github.com/prowler-cloud/prowler/pull/12546)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the SDK container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549)
|
||||
|
||||
---
|
||||
|
||||
## [5.39.1] (Prowler v5.39.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
`defender_domain_dmarc_records_published` checks that every Exchange Online domain publishes a DMARC record with an enforcing policy (`p=quarantine` or `p=reject`)
|
||||
@@ -0,0 +1 @@
|
||||
`PROWLER_AWS_PARTITION` environment variable to select the AWS partition used for STS credential validation and scan bootstrap, with a clear error when the account belongs to a different partition
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user