Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49104f39df |
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.43.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# SDK
|
||||
/* @prowler-cloud/engineering
|
||||
/prowler/ @prowler-cloud/engineering
|
||||
/tests/ @prowler-cloud/engineering
|
||||
/dashboard/ @prowler-cloud/engineering
|
||||
/docs/ @prowler-cloud/engineering
|
||||
/examples/ @prowler-cloud/engineering
|
||||
/util/ @prowler-cloud/engineering
|
||||
/contrib/ @prowler-cloud/engineering
|
||||
/permissions/ @prowler-cloud/engineering
|
||||
/codecov.yml @prowler-cloud/engineering
|
||||
/* @prowler-cloud/detection-remediation
|
||||
/prowler/ @prowler-cloud/detection-remediation
|
||||
/tests/ @prowler-cloud/detection-remediation
|
||||
/dashboard/ @prowler-cloud/detection-remediation
|
||||
/docs/ @prowler-cloud/detection-remediation
|
||||
/examples/ @prowler-cloud/detection-remediation
|
||||
/util/ @prowler-cloud/detection-remediation
|
||||
/contrib/ @prowler-cloud/detection-remediation
|
||||
/permissions/ @prowler-cloud/detection-remediation
|
||||
/codecov.yml @prowler-cloud/detection-remediation @prowler-cloud/api
|
||||
|
||||
# API
|
||||
/api/ @prowler-cloud/engineering
|
||||
/api/ @prowler-cloud/api
|
||||
|
||||
# UI
|
||||
/ui/ @prowler-cloud/engineering
|
||||
/ui/ @prowler-cloud/ui
|
||||
|
||||
# AI
|
||||
/mcp_server/ @prowler-cloud/engineering
|
||||
/mcp_server/ @prowler-cloud/detection-remediation
|
||||
|
||||
# Platform
|
||||
/.github/ @prowler-cloud/platform
|
||||
|
||||
@@ -46,17 +46,6 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if grep -q "prowler-cloud/prowler" uv.lock; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
if [ "$status" -ne 1 ]; then
|
||||
echo "::error::grep failed reading uv.lock (exit code $status)."
|
||||
exit "$status"
|
||||
fi
|
||||
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
|
||||
exit 0
|
||||
fi
|
||||
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
@@ -77,17 +66,6 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if grep -q "prowler-cloud/prowler" uv.lock; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
if [ "$status" -ne 1 ]; then
|
||||
echo "::error::grep failed reading uv.lock (exit code $status)."
|
||||
exit "$status"
|
||||
fi
|
||||
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
|
||||
exit 0
|
||||
fi
|
||||
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Check labels
|
||||
id: label_check
|
||||
uses: agilepathway/label-checker@c324842522fbd012e4f590afe3b4e591301322ed # v1.6.66
|
||||
uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
|
||||
with:
|
||||
allow_failure: true
|
||||
prefix_mode: true
|
||||
|
||||
@@ -44,10 +44,7 @@ jobs:
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
# Pinned to the versions in pyproject.toml: the ISO partitions region
|
||||
# data comes from the endpoints.json bundled with botocore, so the
|
||||
# botocore version is itself a data source and must be deterministic
|
||||
run: pip install boto3==1.40.61 botocore==1.40.61
|
||||
run: pip install boto3
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
|
||||
@@ -17,40 +17,6 @@ ignore:
|
||||
- vulnerability: CVE-2026-71556
|
||||
package:
|
||||
name: github.com/go-git/go-git/v5
|
||||
# CVE-2026-84304 is the same temporary exception documented in .trivyignore.yaml:
|
||||
# Trivy 0.74.0 still embeds grpc 1.82.1, while the 1.83.1 fix is not in any release.
|
||||
# Prowler only runs `trivy image` / `trivy fs`, never client/server mode, so no gRPC
|
||||
# endpoint exists in the image. Pinned to the embedded version so the rule stops
|
||||
# matching on its own once Trivy bumps grpc. Remove with the Trivy exception by 2026-10-15.
|
||||
# https://github.com/aquasecurity/trivy/pull/11176
|
||||
- vulnerability: CVE-2026-84304
|
||||
package:
|
||||
name: google.golang.org/grpc
|
||||
version: v1.82.1
|
||||
# CVE-2026-84445 is the same temporary exception documented in .trivyignore.yaml:
|
||||
# Trivy 0.74.0 still embeds grpc 1.82.1, while the 1.82.2 / 1.83.2 fix is not in any
|
||||
# release. The panic needs a gRPC server built with `xds.NewGRPCServer()`; Prowler only
|
||||
# runs `trivy image` / `trivy fs`, so the image serves no gRPC at all. Pinned to the
|
||||
# embedded version so the rule stops matching on its own once Trivy bumps grpc. Remove
|
||||
# with the Trivy exception by 2026-10-15.
|
||||
# https://github.com/advisories/GHSA-2v4p-qf9q-27wj
|
||||
- vulnerability: CVE-2026-84445
|
||||
package:
|
||||
name: google.golang.org/grpc
|
||||
version: v1.82.1
|
||||
# CVE-2026-56855 / CVE-2026-78662 are the same temporary exception documented in
|
||||
# .trivyignore.yaml: Trivy 0.74.0 still embeds golang.org/x/crypto v0.55.0, while the
|
||||
# 0.56.0 fix (published 2026-09-02) hasn't reached any Trivy release, or even Trivy
|
||||
# main, yet. Pinned to the embedded version so the rule stops matching on its own once
|
||||
# Trivy bumps it. Remove with the Trivy exception by 2026-10-15.
|
||||
- vulnerability: CVE-2026-56855
|
||||
package:
|
||||
name: golang.org/x/crypto
|
||||
version: v0.55.0
|
||||
- vulnerability: CVE-2026-78662
|
||||
package:
|
||||
name: golang.org/x/crypto
|
||||
version: v0.55.0
|
||||
- vulnerability: CVE-2026-56852
|
||||
package:
|
||||
name: golang.org/x/text
|
||||
|
||||
@@ -113,18 +113,6 @@ vulnerabilities:
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-75899
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-75975
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-76172
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-69192
|
||||
purls:
|
||||
- "pkg:npm/ip-address"
|
||||
@@ -160,62 +148,6 @@ vulnerabilities:
|
||||
- "pkg:golang/github.com/go-git/go-git/v5"
|
||||
expired_at: 2026-09-15
|
||||
|
||||
# CVE-2026-84304 is a DoS in grpc-go <= 1.83.0: a peer fragments a gRPC stream into
|
||||
# millions of tiny HTTP/2 DATA frames until the receiver runs out of heap. Fixed in
|
||||
# 1.83.1 (published 2026-09-01). Trivy 0.74.0, the latest published release and the
|
||||
# version the images ship, pins 1.82.1 as an indirect dependency:
|
||||
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
|
||||
# Upstream bump still open: https://github.com/aquasecurity/trivy/pull/11176
|
||||
# Trivy only speaks gRPC in client/server mode (`trivy server`, `--server`). Prowler
|
||||
# invokes it exclusively as `trivy image` and `trivy fs` on a local path, so no gRPC
|
||||
# listener or connection ever exists in the image and the affected path is not
|
||||
# reachable. Remove this temporary suppression as soon as a Trivy release pins
|
||||
# grpc >= 1.83.1.
|
||||
- id: CVE-2026-84304
|
||||
purls:
|
||||
- "pkg:golang/google.golang.org/grpc"
|
||||
expired_at: 2026-10-15
|
||||
|
||||
# CVE-2026-84445 is a DoS in grpc-go servers built with `xds.NewGRPCServer()`: a request
|
||||
# carrying neither `:authority` nor `Host` reaches the xDS routing interceptor, which
|
||||
# indexes an empty slice of authorities and panics. The per-RPC goroutine does not
|
||||
# recover, so the whole server process dies. Fixed in 1.82.2 and 1.83.2 (published
|
||||
# 2026-09-08). Trivy 0.74.0, the latest published release and the version the images
|
||||
# ship, pins 1.82.1 as an indirect dependency:
|
||||
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
|
||||
# Trivy main already carries 1.83.2, but no published release includes it yet.
|
||||
# The reachability argument is the one made for CVE-2026-84304 above, only narrower:
|
||||
# this panic needs an xDS-managed gRPC server. Prowler invokes Trivy exclusively as
|
||||
# `trivy image` and `trivy fs` on a local path, never `trivy server`, so the image runs
|
||||
# no gRPC server at all, xDS or otherwise. Remove this temporary suppression as soon as
|
||||
# a Trivy release pins grpc >= 1.83.2.
|
||||
# https://github.com/advisories/GHSA-2v4p-qf9q-27wj
|
||||
- id: CVE-2026-84445
|
||||
purls:
|
||||
- "pkg:golang/google.golang.org/grpc@v1.82.1"
|
||||
expired_at: 2026-10-15
|
||||
|
||||
# CVE-2026-56855 and CVE-2026-78662 are DoS deadlocks in x/crypto/ssh: a malicious peer
|
||||
# can flood or misuse channel messages (RFC 4254) to block the whole connection.
|
||||
# Fixed in golang.org/x/crypto v0.56.0 (published 2026-09-02). Trivy 0.74.0, the latest
|
||||
# published release and the version the images ship, still pins v0.55.0, and Trivy main
|
||||
# has not bumped it either:
|
||||
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
|
||||
# x/crypto/ssh is pulled in transitively through go-git's ssh transport, the same
|
||||
# dependency chain as the CVE-2026-71556 entry above. Prowler invokes Trivy only with
|
||||
# `fs` on an existing local path or with `image`; it never asks Trivy to clone over SSH
|
||||
# or to run `trivy server`, so no SSH connection -- as client or server -- ever exists in
|
||||
# the image and the affected code path is not reachable. Remove this temporary
|
||||
# suppression as soon as a fixed Trivy release is available.
|
||||
- id: CVE-2026-56855
|
||||
purls:
|
||||
- "pkg:golang/golang.org/x/crypto@v0.55.0"
|
||||
expired_at: 2026-10-15
|
||||
- id: CVE-2026-78662
|
||||
purls:
|
||||
- "pkg:golang/golang.org/x/crypto@v0.55.0"
|
||||
expired_at: 2026-10-15
|
||||
|
||||
- id: CVE-2026-56852
|
||||
purls:
|
||||
- "pkg:golang/golang.org/x/text"
|
||||
|
||||
@@ -22,25 +22,20 @@ ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb0
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
|
||||
|
||||
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
|
||||
# High CVEs fixed in Debian trixie-security but not yet in the pinned base image:
|
||||
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
|
||||
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
|
||||
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
|
||||
# gzip 1.13-1+deb13u1 CVE-2026-41992
|
||||
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
|
||||
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
|
||||
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
|
||||
# (image ships 3.5.6-1~deb13u2)
|
||||
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
|
||||
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
|
||||
# openssl packages are flagged separately, so all are named.
|
||||
# Drop each one once the base image ships its fixed version.
|
||||
# published python:3.12-slim-trixie carries the same vulnerable version. The three
|
||||
# packages are all built from openssl and are flagged separately, so all are named.
|
||||
# Drop them once the base image ships 3.5.7-1~deb13u2 or later.
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
build-essential pkg-config libzstd-dev zlib1g-dev \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade \
|
||||
util-linux libssl3t64 openssl openssl-provider-legacy \
|
||||
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
|
||||
@@ -139,7 +139,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
|
||||
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| Google Workspace | 65 | 11 | 4 | 6 | Official | UI, API, CLI |
|
||||
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
|
||||
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
|
||||
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
|
||||
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
|
||||
|
||||
@@ -59,9 +59,5 @@ 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,26 +4,6 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.43.0] (Prowler v5.42.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Speed up compliance overview ingestion by reading ThreatScore mappings from the compliance template instead of each finding, generating time-ordered `uuid7` row ids and grouping inserted rows by framework and requirement [(#12738)](https://github.com/prowler-cloud/prowler/pull/12738)
|
||||
|
||||
---
|
||||
|
||||
## [1.42.0] (Prowler v5.41.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- 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 [(#12540)](https://github.com/prowler-cloud/prowler/pull/12540)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues [(#12681)](https://github.com/prowler-cloud/prowler/pull/12681)
|
||||
|
||||
---
|
||||
|
||||
## [1.41.0] (Prowler v5.40.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -21,18 +21,14 @@ ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb0
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
|
||||
|
||||
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
|
||||
# High CVEs fixed in Debian trixie-security but not yet in the pinned base image:
|
||||
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
|
||||
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
|
||||
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
|
||||
# gzip 1.13-1+deb13u1 CVE-2026-41992
|
||||
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
|
||||
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
|
||||
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
|
||||
# (image ships 3.5.6-1~deb13u2)
|
||||
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
|
||||
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
|
||||
# openssl packages are flagged separately, so all are named.
|
||||
# Drop each one once the base image ships its fixed version.
|
||||
# published python:3.12-slim-trixie carries the same vulnerable version. The three
|
||||
# packages are all built from openssl and are flagged separately, so all are named.
|
||||
# Drop them once the base image ships 3.5.7-1~deb13u2 or later.
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
@@ -50,7 +46,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3-dev \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade \
|
||||
util-linux libssl3t64 openssl openssl-provider-legacy \
|
||||
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
`libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs
|
||||
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.40",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.44.0"
|
||||
version = "1.41.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.44.0
|
||||
version: 1.41.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -18333,14 +18333,19 @@ class TestMuteRuleViewSet:
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
|
||||
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
@patch("api.v1.views.chain")
|
||||
@patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si")
|
||||
@patch("api.v1.views.mute_historical_findings_task.si")
|
||||
@patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
|
||||
def test_mute_rules_create_valid(
|
||||
self,
|
||||
_mock_on_commit,
|
||||
mock_mute_task,
|
||||
mock_mute_signature,
|
||||
mock_reaggregate_signature,
|
||||
mock_chain,
|
||||
authenticated_client,
|
||||
findings_fixture,
|
||||
create_test_user,
|
||||
):
|
||||
"""Test creating a valid mute rule."""
|
||||
finding_ids = [str(findings_fixture[0].id)]
|
||||
@@ -18367,20 +18372,24 @@ class TestMuteRuleViewSet:
|
||||
assert response_data["attributes"]["name"] == "New Mute Rule"
|
||||
assert response_data["attributes"]["reason"] == "Security exception approved"
|
||||
|
||||
# Verify the finding was immediately muted
|
||||
from api.models import Finding
|
||||
|
||||
finding = Finding.objects.get(id=findings_fixture[0].id)
|
||||
assert finding.muted is False
|
||||
assert finding.muted_at is None
|
||||
assert finding.muted_reason is None
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at is not None
|
||||
assert finding.muted_reason == "Security exception approved"
|
||||
|
||||
mock_mute_task.assert_called_once_with(
|
||||
kwargs={
|
||||
"tenant_id": str(finding.tenant_id),
|
||||
"mute_rule_id": response_data["id"],
|
||||
"provider_ids": [str(finding.scan.provider_id)],
|
||||
}
|
||||
# Verify background task chain was called: mute → reaggregate all
|
||||
mock_mute_signature.assert_called_once()
|
||||
mock_reaggregate_signature.assert_called_once()
|
||||
mock_chain.assert_called_once_with(
|
||||
mock_mute_signature.return_value,
|
||||
mock_reaggregate_signature.return_value,
|
||||
)
|
||||
mock_chain.return_value.apply_async.assert_called_once()
|
||||
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
def test_mute_rules_create_converts_finding_ids_to_uids(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18416,7 +18425,7 @@ class TestMuteRuleViewSet:
|
||||
]
|
||||
assert set(mute_rule.finding_uids) == set(expected_uids)
|
||||
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
def test_mute_rules_deduplicates_uids(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18483,10 +18492,10 @@ class TestMuteRuleViewSet:
|
||||
|
||||
finding1.refresh_from_db()
|
||||
finding2.refresh_from_db()
|
||||
assert finding1.muted is False
|
||||
assert finding2.muted is False
|
||||
assert finding1.muted is True
|
||||
assert finding2.muted is True
|
||||
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
def test_mute_rules_create_overlap_detection_active(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18519,7 +18528,7 @@ class TestMuteRuleViewSet:
|
||||
"already muted" in error_detail.lower() or "overlap" in error_detail.lower()
|
||||
)
|
||||
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
def test_mute_rules_create_no_overlap_with_inactive(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18575,7 +18584,7 @@ class TestMuteRuleViewSet:
|
||||
== "/data/attributes/finding_ids"
|
||||
)
|
||||
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
def test_mute_rules_create_invalid_finding_ids(
|
||||
self, mock_task, authenticated_client
|
||||
):
|
||||
|
||||
@@ -244,6 +244,7 @@ from api.v1.serializers import (
|
||||
UserUpdateSerializer,
|
||||
)
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
||||
from celery import chain
|
||||
from celery.result import AsyncResult
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.env import env
|
||||
@@ -341,7 +342,8 @@ from tasks.tasks import (
|
||||
enqueue_scan_execution_on_commit,
|
||||
get_active_provider_scan,
|
||||
jira_integration_task,
|
||||
mute_findings_in_latest_scans_task,
|
||||
mute_historical_findings_task,
|
||||
reaggregate_all_finding_group_summaries_task,
|
||||
refresh_lighthouse_provider_models_task,
|
||||
)
|
||||
|
||||
@@ -7549,28 +7551,35 @@ class MuteRuleViewSet(BaseRLSViewSet):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
tenant_id = str(request.tenant_id)
|
||||
finding_ids = serializer.validated_data["finding_ids"]
|
||||
provider_ids = list(
|
||||
dict.fromkeys(
|
||||
Finding.all_objects.filter(
|
||||
id__in=finding_ids, tenant_id=tenant_id
|
||||
).values_list("scan__provider_id", flat=True)
|
||||
)
|
||||
)
|
||||
|
||||
# Create the mute rule
|
||||
mute_rule = serializer.save()
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: mute_findings_in_latest_scans_task.apply_async(
|
||||
kwargs={
|
||||
"tenant_id": tenant_id,
|
||||
"mute_rule_id": str(mute_rule.id),
|
||||
"provider_ids": [str(provider_id) for provider_id in provider_ids],
|
||||
}
|
||||
)
|
||||
tenant_id = str(request.tenant_id)
|
||||
finding_ids = request.data.get("finding_ids", [])
|
||||
|
||||
# Immediately mute the selected findings
|
||||
Finding.all_objects.filter(
|
||||
id__in=finding_ids, tenant_id=tenant_id, muted=False
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=mute_rule.inserted_at,
|
||||
muted_reason=mute_rule.reason,
|
||||
)
|
||||
|
||||
# Launch background task for historical muting + reaggregation
|
||||
transaction.on_commit(
|
||||
lambda: chain(
|
||||
mute_historical_findings_task.si(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=str(mute_rule.id),
|
||||
),
|
||||
reaggregate_all_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id,
|
||||
),
|
||||
).apply_async()
|
||||
)
|
||||
|
||||
# Return the created mute rule
|
||||
serializer = self.get_serializer(mute_rule)
|
||||
return Response(
|
||||
data=serializer.data,
|
||||
|
||||
@@ -303,11 +303,6 @@ 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,16 +2,13 @@ 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
|
||||
@@ -19,7 +16,6 @@ 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
|
||||
@@ -481,55 +477,6 @@ 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,
|
||||
@@ -540,7 +487,6 @@ 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 = []
|
||||
@@ -573,15 +519,6 @@ 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(
|
||||
@@ -590,7 +527,7 @@ def send_findings_to_jira(
|
||||
severity=finding_instance.severity,
|
||||
status=finding_instance.status,
|
||||
status_extended=finding_instance.status_extended or "",
|
||||
provider=provider_type,
|
||||
provider=finding_instance.scan.provider.provider,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
@@ -605,9 +542,6 @@ 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
|
||||
@@ -623,11 +557,6 @@ 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)
|
||||
|
||||
@@ -1,104 +1,63 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, MuteRule, Scan, StateChoices
|
||||
from api.models import Finding, MuteRule
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from tasks.utils import batched
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def _mute_findings_for_rule(
|
||||
*,
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
finding_uids: Iterable[str],
|
||||
muted_at,
|
||||
muted_reason: str,
|
||||
) -> int:
|
||||
finding_uids = list(finding_uids)
|
||||
if not finding_uids:
|
||||
return 0
|
||||
def mute_historical_findings(tenant_id: str, mute_rule_id: str):
|
||||
"""
|
||||
Mute historical findings that match the given mute rule.
|
||||
|
||||
return Finding.all_objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
uid__in=finding_uids,
|
||||
muted=False,
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=muted_at,
|
||||
muted_reason=muted_reason,
|
||||
)
|
||||
This function processes findings in batches, updating their muted status
|
||||
and adding the mute reason.
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for RLS context
|
||||
mute_rule_id (str): The ID of the mute rule to apply
|
||||
|
||||
def mute_findings_in_latest_scans(
|
||||
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
|
||||
) -> dict:
|
||||
"""Apply a mute rule to the latest completed scan of each provider."""
|
||||
provider_ids = list(dict.fromkeys(provider_ids))
|
||||
Returns:
|
||||
dict: Summary of the muting operation with findings_muted count
|
||||
"""
|
||||
findings_muted_count = 0
|
||||
|
||||
# Get the list of UIDs to mute and the reason
|
||||
with rls_transaction(tenant_id):
|
||||
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
|
||||
latest_scans = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id__in=provider_ids,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at", "-id")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
changed_scan_ids = []
|
||||
findings_muted = 0
|
||||
for scan_id in latest_scans:
|
||||
updated = _mute_findings_for_rule(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=str(scan_id),
|
||||
finding_uids=mute_rule.finding_uids,
|
||||
muted_at=mute_rule.inserted_at,
|
||||
muted_reason=mute_rule.reason,
|
||||
)
|
||||
if updated:
|
||||
findings_muted += updated
|
||||
changed_scan_ids.append(str(scan_id))
|
||||
|
||||
logger.info(
|
||||
"Muted %d findings in %d latest scans for rule %s",
|
||||
findings_muted,
|
||||
len(changed_scan_ids),
|
||||
mute_rule_id,
|
||||
)
|
||||
return {
|
||||
"findings_muted": findings_muted,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": changed_scan_ids,
|
||||
}
|
||||
|
||||
|
||||
def reconcile_scan_mute_rules(tenant_id: str, scan_id: str) -> dict:
|
||||
"""Apply the current enabled mute rules to one completed scan."""
|
||||
findings_muted = 0
|
||||
finding_uids = mute_rule.finding_uids
|
||||
mute_reason = mute_rule.reason
|
||||
muted_at = mute_rule.inserted_at
|
||||
|
||||
# Query findings that match the UIDs and are not already muted
|
||||
with rls_transaction(tenant_id):
|
||||
mute_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True).values(
|
||||
"finding_uids", "reason", "inserted_at"
|
||||
findings_to_mute = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=finding_uids, muted=False
|
||||
)
|
||||
total_findings = findings_to_mute.count()
|
||||
|
||||
logger.info(
|
||||
f"Processing {total_findings} findings for mute rule {mute_rule_id}"
|
||||
)
|
||||
|
||||
for mute_rule in mute_rules:
|
||||
findings_muted += _mute_findings_for_rule(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
finding_uids=mute_rule["finding_uids"],
|
||||
muted_at=mute_rule["inserted_at"],
|
||||
muted_reason=mute_rule["reason"],
|
||||
)
|
||||
if total_findings > 0:
|
||||
for batch, is_last in batched(
|
||||
findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE
|
||||
):
|
||||
batch_ids = [f.id for f in batch]
|
||||
updated_count = Finding.all_objects.filter(
|
||||
id__in=batch_ids, tenant_id=tenant_id
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=muted_at,
|
||||
muted_reason=mute_reason,
|
||||
)
|
||||
findings_muted_count += updated_count
|
||||
|
||||
logger.info(
|
||||
"Reconciled mute rules for scan %s; muted %d findings",
|
||||
scan_id,
|
||||
findings_muted,
|
||||
)
|
||||
return {"findings_muted": findings_muted, "scan_id": str(scan_id)}
|
||||
logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}")
|
||||
|
||||
return {
|
||||
"findings_muted": findings_muted_count,
|
||||
"rule_id": mute_rule_id,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import UTC, datetime
|
||||
@@ -72,7 +73,6 @@ from tasks.jobs.queries import (
|
||||
COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
|
||||
)
|
||||
from tasks.utils import CustomEncoder, batched
|
||||
from uuid6 import uuid7
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -1756,27 +1756,32 @@ def aggregate_findings(tenant_id: str, scan_id: str):
|
||||
|
||||
|
||||
def _aggregate_findings_by_region(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
normalized_threatscore_id: str,
|
||||
threatscore_requirements_by_check: dict[str, list[str]],
|
||||
tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str
|
||||
) -> tuple[dict, dict]:
|
||||
"""
|
||||
Aggregate findings by region using streaming, column-scoped ORM reads.
|
||||
|
||||
Reads only the consumed columns as tuples via ``values_list`` and streams
|
||||
them with ``.iterator()``, using the denormalized ``resource_regions`` array
|
||||
instead of ``prefetch_related("resources")``. ThreatScore requirement ids
|
||||
are resolved per ``check_id`` from ``threatscore_requirements_by_check``.
|
||||
instead of ``prefetch_related("resources")``. ``resource_regions`` mirrors the
|
||||
regions of a finding's related resources, so it yields the same per-region
|
||||
tally without joining the resource table.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
scan_id: Scan UUID
|
||||
modeled_threatscore_compliance_id: ID for ThreatScore compliance framework
|
||||
|
||||
Returns:
|
||||
tuple: (check_status_by_region, findings_count_by_compliance)
|
||||
- check_status_by_region: {region: {check_id: status}}
|
||||
- findings_count_by_compliance: {region: {normalized_threatscore_id: {requirement_id: {total, pass}}}}
|
||||
- findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}}
|
||||
"""
|
||||
check_status_by_region: dict = {}
|
||||
findings_count_by_compliance: dict = {}
|
||||
|
||||
normalized_id = re.sub(r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower())
|
||||
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
findings = (
|
||||
Finding.all_objects.filter(
|
||||
@@ -1785,12 +1790,14 @@ def _aggregate_findings_by_region(
|
||||
muted=False,
|
||||
status__in=["PASS", "FAIL"],
|
||||
)
|
||||
.values_list("check_id", "status", "resource_regions")
|
||||
.values_list("check_id", "status", "resource_regions", "compliance")
|
||||
.iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
|
||||
)
|
||||
|
||||
for check_id, status, resource_regions in findings:
|
||||
threatscore_requirements = threatscore_requirements_by_check.get(check_id)
|
||||
for check_id, status, resource_regions, compliance in findings:
|
||||
threatscore_requirements = (compliance or {}).get(
|
||||
modeled_threatscore_compliance_id
|
||||
)
|
||||
|
||||
for region in resource_regions or ():
|
||||
# Priority: FAIL > any other status
|
||||
@@ -1802,7 +1809,7 @@ def _aggregate_findings_by_region(
|
||||
if threatscore_requirements:
|
||||
compliance_key = findings_count_by_compliance.setdefault(
|
||||
region, {}
|
||||
).setdefault(normalized_threatscore_id, {})
|
||||
).setdefault(normalized_id, {})
|
||||
|
||||
for requirement_id in threatscore_requirements:
|
||||
requirement_stats = compliance_key.setdefault(
|
||||
@@ -1841,28 +1848,15 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
|
||||
provider_instance.provider
|
||||
]
|
||||
normalized_threatscore_id = _normalized_compliance_key(
|
||||
"ProwlerThreatScore", "1.0"
|
||||
)
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
|
||||
requirement_lookup: dict[str, list[tuple[str, str]]] = {}
|
||||
threatscore_requirements_by_check: dict[str, list[str]] = {}
|
||||
for compliance_id, compliance in compliance_template.items():
|
||||
is_threatscore = (
|
||||
_normalized_compliance_key(
|
||||
compliance["framework"], compliance["version"]
|
||||
)
|
||||
== normalized_threatscore_id
|
||||
)
|
||||
for requirement_id, requirement in compliance["requirements"].items():
|
||||
for check_id in requirement["checks"].keys():
|
||||
requirement_lookup.setdefault(check_id, []).append(
|
||||
(compliance_id, requirement_id)
|
||||
)
|
||||
if is_threatscore:
|
||||
threatscore_requirements_by_check.setdefault(
|
||||
check_id, []
|
||||
).append(requirement_id)
|
||||
|
||||
regions = []
|
||||
requirements_created = 0
|
||||
@@ -1875,10 +1869,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
# Aggregate findings by region using SQL for optimal performance
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_threatscore_id,
|
||||
threatscore_requirements_by_check,
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1943,35 +1934,23 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
# Yield rows lazily (consumed batch-by-batch by COPY) so peak memory
|
||||
# stays bounded; tally requirement_statuses in the same pass. The
|
||||
# ORM fallback re-iterates from scratch, so the tally resets first.
|
||||
# Region is the innermost loop so consecutive rows share the leading
|
||||
# columns of the table's secondary indexes.
|
||||
def _iter_compliance_requirement_rows():
|
||||
requirement_statuses.clear()
|
||||
for (
|
||||
compliance_id,
|
||||
framework,
|
||||
version,
|
||||
modeled_compliance_id,
|
||||
requirements,
|
||||
) in compliance_plan:
|
||||
stats_by_region = [
|
||||
(
|
||||
region,
|
||||
region_requirement_stats.get(region, {}).get(
|
||||
compliance_id, {}
|
||||
),
|
||||
findings_count_by_compliance.get(region, {}).get(
|
||||
modeled_compliance_id, {}
|
||||
),
|
||||
for region in regions:
|
||||
region_stats = region_requirement_stats.get(region, {})
|
||||
region_findings = findings_count_by_compliance.get(region, {})
|
||||
for (
|
||||
compliance_id,
|
||||
framework,
|
||||
version,
|
||||
modeled_compliance_id,
|
||||
requirements,
|
||||
) in compliance_plan:
|
||||
compliance_stats = region_stats.get(compliance_id, {})
|
||||
compliance_findings = region_findings.get(
|
||||
modeled_compliance_id, {}
|
||||
)
|
||||
for region in regions
|
||||
]
|
||||
for requirement_id, description, total_checks in requirements:
|
||||
for (
|
||||
region,
|
||||
compliance_stats,
|
||||
compliance_findings,
|
||||
) in stats_by_region:
|
||||
for requirement_id, description, total_checks in requirements:
|
||||
stats = compliance_stats.get(requirement_id)
|
||||
if stats:
|
||||
passed_checks = stats["passed_checks"]
|
||||
@@ -2002,7 +1981,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
requirement_statuses[key]["pass_count"] += 1
|
||||
|
||||
yield {
|
||||
"id": uuid7(),
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id_str,
|
||||
"inserted_at": utc_datetime_now,
|
||||
"compliance_id": compliance_id,
|
||||
|
||||
@@ -73,10 +73,7 @@ from tasks.jobs.lighthouse_providers import (
|
||||
check_lighthouse_provider_connection,
|
||||
refresh_lighthouse_provider_models,
|
||||
)
|
||||
from tasks.jobs.muting import (
|
||||
mute_findings_in_latest_scans,
|
||||
reconcile_scan_mute_rules,
|
||||
)
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
from tasks.jobs.orphan_recovery import reconcile_orphans
|
||||
from tasks.jobs.report import (
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
@@ -529,7 +526,6 @@ def perform_scan_task(
|
||||
provider_id=provider_id,
|
||||
checks_to_execute=checks_to_execute,
|
||||
)
|
||||
reconcile_scan_mute_rules(tenant_id, scan_id)
|
||||
_perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
|
||||
return result
|
||||
finally:
|
||||
@@ -639,7 +635,6 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
|
||||
scan_id=str(scan_instance.id),
|
||||
provider_id=provider_id,
|
||||
)
|
||||
reconcile_scan_mute_rules(tenant_id, str(scan_instance.id))
|
||||
_perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
|
||||
return result
|
||||
finally:
|
||||
@@ -1193,48 +1188,85 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
|
||||
return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
|
||||
|
||||
|
||||
def _dispatch_scan_summary_reaggregation(tenant_id: str, scan_ids: list[str]) -> None:
|
||||
if not scan_ids:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Reaggregating overview/finding summaries for %d latest scans",
|
||||
len(scan_ids),
|
||||
)
|
||||
group(
|
||||
chain(
|
||||
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
group(
|
||||
aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_resource_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_category_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_attack_surface_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
),
|
||||
)
|
||||
for scan_id in scan_ids
|
||||
).apply_async()
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="findings-mute-latest-scans", queue="overview")
|
||||
@shared_task(
|
||||
base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview"
|
||||
)
|
||||
@set_tenant(keep_tenant=True)
|
||||
def mute_findings_in_latest_scans_task(
|
||||
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
|
||||
):
|
||||
"""Apply a mute rule to current scans and rebuild only changed summaries."""
|
||||
result = mute_findings_in_latest_scans(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
|
||||
"""Reaggregate every pre-aggregated summary table for this tenant.
|
||||
|
||||
Mirrors the unbounded scope of `mute_historical_findings_task`: that task
|
||||
rewrites every Finding row whose UID matches a mute rule, with no time
|
||||
limit. To keep the pre-aggregated tables consistent with that update,
|
||||
this task re-runs the same per-scan aggregation pipeline that scan
|
||||
completion runs on the latest completed scan of every (provider, day)
|
||||
pair, rebuilding the tables that power the read endpoints:
|
||||
|
||||
- `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`,
|
||||
`/overviews/findings-severity`, `/overviews/services`.
|
||||
- `FindingGroupDailySummary` -> `/finding-groups` and
|
||||
`/finding-groups/latest`.
|
||||
- `ScanGroupSummary` -> `/overviews/resource-groups` (resource
|
||||
inventory).
|
||||
- `ScanCategorySummary` -> `/overviews/categories`.
|
||||
- `AttackSurfaceOverview` -> `/overviews/attack-surfaces`.
|
||||
|
||||
Per-scan pipelines are dispatched in parallel via a Celery group so
|
||||
wallclock scales with the worker pool.
|
||||
"""
|
||||
completed_scans = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("-completed_at")
|
||||
.values("id", "completed_at", "provider_id")
|
||||
)
|
||||
_dispatch_scan_summary_reaggregation(tenant_id, result["scan_ids"])
|
||||
return result
|
||||
|
||||
# Keep the latest scan per (provider, day) pair so the daily summary row
|
||||
# the aggregator writes is the most recent snapshot of that day for that
|
||||
# provider. Iterating from most recent to oldest means the first scan we
|
||||
# see for a given key wins.
|
||||
latest_scans: dict[tuple, str] = {}
|
||||
for scan in completed_scans:
|
||||
key = (scan["provider_id"], scan["completed_at"].date())
|
||||
if key not in latest_scans:
|
||||
latest_scans[key] = str(scan["id"])
|
||||
|
||||
scan_ids = list(latest_scans.values())
|
||||
if scan_ids:
|
||||
logger.info(
|
||||
"Reaggregating overview/finding summaries for %d scans (provider x day)",
|
||||
len(scan_ids),
|
||||
)
|
||||
# DailySeveritySummary reads from ScanSummary, so ScanSummary must be
|
||||
# recomputed first; the other aggregators read Finding directly and
|
||||
# can run in parallel with the severity step.
|
||||
group(
|
||||
chain(
|
||||
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
group(
|
||||
aggregate_daily_severity_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_resource_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_category_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_attack_surface_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
),
|
||||
)
|
||||
for scan_id in scan_ids
|
||||
).apply_async()
|
||||
return {"scans_reaggregated": len(scan_ids)}
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="lighthouse-connection-check")
|
||||
@@ -1435,3 +1467,25 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
|
||||
generate_csa=True,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(name="findings-mute-historical")
|
||||
def mute_historical_findings_task(tenant_id: str, mute_rule_id: str):
|
||||
"""
|
||||
Background task to mute all historical findings matching a mute rule.
|
||||
|
||||
This task processes findings in batches to avoid memory issues with large datasets.
|
||||
It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields
|
||||
for all findings whose UID is in the mute rule's finding_uids list.
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for RLS context.
|
||||
mute_rule_id (str): The primary key of the MuteRule to apply.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing:
|
||||
- 'findings_muted' (int): Total number of findings muted.
|
||||
- 'rule_id' (str): The mute rule ID.
|
||||
- 'status' (str): Final status ('completed').
|
||||
"""
|
||||
return mute_historical_findings(tenant_id, mute_rule_id)
|
||||
|
||||
@@ -6,20 +6,15 @@ 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,
|
||||
@@ -1701,7 +1696,6 @@ 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"
|
||||
@@ -1730,7 +1724,6 @@ 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"
|
||||
@@ -1755,13 +1748,9 @@ class TestJiraIntegration:
|
||||
]
|
||||
|
||||
# Call the function
|
||||
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
|
||||
)
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 2, "failed_count": 0}
|
||||
@@ -1784,36 +1773,12 @@ 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")
|
||||
@@ -2235,101 +2200,3 @@ 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") == ""
|
||||
|
||||
@@ -1,205 +1,531 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.models import Finding, MuteRule, Scan, StateChoices
|
||||
from api.models import Finding, MuteRule
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from tasks.jobs.muting import (
|
||||
mute_findings_in_latest_scans,
|
||||
reconcile_scan_mute_rules,
|
||||
)
|
||||
|
||||
|
||||
def _create_finding(scan: Scan, uid: str) -> Finding:
|
||||
return Finding.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
uid=uid,
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended="Test finding",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={},
|
||||
check_id="test_check",
|
||||
check_metadata={"CheckId": "test_check"},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
|
||||
def _create_mute_rule(tenant_id, user, finding_uids, *, enabled=True) -> MuteRule:
|
||||
return MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name=f"Mute rule {uuid4()}",
|
||||
reason="Approved exception",
|
||||
enabled=enabled,
|
||||
created_by=user,
|
||||
finding_uids=finding_uids,
|
||||
)
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMuteFindingsInLatestScans:
|
||||
def test_mutes_latest_scan_and_leaves_older_scan_unchanged(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
latest_scan = scans_fixture[0]
|
||||
older_scan = Scan.objects.create(
|
||||
tenant_id=latest_scan.tenant_id,
|
||||
provider=latest_scan.provider,
|
||||
name="Older scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC) - timedelta(days=1),
|
||||
completed_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
uid = "latest-scan-only"
|
||||
older_finding = _create_finding(older_scan, uid)
|
||||
latest_finding = _create_finding(latest_scan, uid)
|
||||
mute_rule = _create_mute_rule(latest_scan.tenant_id, create_test_user, [uid])
|
||||
class TestMuteHistoricalFindings:
|
||||
"""
|
||||
Test suite for the mute_historical_findings function.
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(latest_scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(latest_scan.provider_id)],
|
||||
)
|
||||
This class tests the batch processing of findings to update their muted status
|
||||
based on MuteRule criteria.
|
||||
"""
|
||||
|
||||
older_finding.refresh_from_db()
|
||||
latest_finding.refresh_from_db()
|
||||
assert older_finding.muted is False
|
||||
assert latest_finding.muted is True
|
||||
assert latest_finding.muted_at == mute_rule.inserted_at
|
||||
assert latest_finding.muted_reason == mute_rule.reason
|
||||
assert result == {
|
||||
"findings_muted": 1,
|
||||
"rule_id": str(mute_rule.id),
|
||||
"scan_ids": [str(latest_scan.id)],
|
||||
}
|
||||
@pytest.fixture(scope="function")
|
||||
def test_user(self, create_test_user):
|
||||
"""Create a test user for mute rule creation."""
|
||||
return create_test_user
|
||||
|
||||
def test_mutes_one_latest_scan_per_provider(self, scans_fixture, create_test_user):
|
||||
first_scan, second_scan, _ = scans_fixture
|
||||
uid = "shared-selected-uid"
|
||||
first_finding = _create_finding(first_scan, uid)
|
||||
second_finding = _create_finding(second_scan, uid)
|
||||
mute_rule = _create_mute_rule(first_scan.tenant_id, create_test_user, [uid])
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(first_scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(first_scan.provider_id), str(second_scan.provider_id)],
|
||||
)
|
||||
|
||||
first_finding.refresh_from_db()
|
||||
second_finding.refresh_from_db()
|
||||
assert first_finding.muted is True
|
||||
assert second_finding.muted is True
|
||||
assert result["findings_muted"] == 2
|
||||
assert set(result["scan_ids"]) == {str(first_scan.id), str(second_scan.id)}
|
||||
|
||||
def test_provider_without_completed_scan_does_nothing(
|
||||
self, tenants_fixture, provider_factory, create_test_user
|
||||
):
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule that targets the first finding in the fixture.
|
||||
"""
|
||||
tenant = tenants_fixture[0]
|
||||
provider = provider_factory()
|
||||
mute_rule = _create_mute_rule(
|
||||
tenant.id, create_test_user, ["future-scan-finding"]
|
||||
finding = findings_fixture[0]
|
||||
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
name="Test Mute Rule",
|
||||
reason="Testing mute functionality",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[finding.uid],
|
||||
)
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(tenant.id), str(mute_rule.id), [str(provider.id)]
|
||||
)
|
||||
return mute_rule
|
||||
|
||||
assert result == {
|
||||
"findings_muted": 0,
|
||||
"rule_id": str(mute_rule.id),
|
||||
"scan_ids": [],
|
||||
}
|
||||
|
||||
def test_retry_does_not_report_changed_scans_twice(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_multiple_findings(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create multiple unmuted findings and a mute rule targeting all of them.
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
finding = _create_finding(scan, "idempotent-mute")
|
||||
mute_rule = _create_mute_rule(scan.tenant_id, create_test_user, [finding.uid])
|
||||
args = (
|
||||
str(scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(scan.provider_id)],
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 5 unmuted findings
|
||||
finding_uids = []
|
||||
for i in range(5):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"test_finding_uid_mute_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Test status {i}",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.high,
|
||||
"severity": Severity.high,
|
||||
},
|
||||
check_id=f"test_check_id_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"test_check_id_{i}",
|
||||
"Description": f"Test description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
finding_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Multiple Findings Mute Rule",
|
||||
reason="Testing batch muting",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=finding_uids,
|
||||
)
|
||||
|
||||
first_result = mute_findings_in_latest_scans(*args)
|
||||
second_result = mute_findings_in_latest_scans(*args)
|
||||
return mute_rule, finding_uids
|
||||
|
||||
assert first_result["scan_ids"] == [str(scan.id)]
|
||||
assert second_result["findings_muted"] == 0
|
||||
assert second_result["scan_ids"] == []
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_already_muted(self, findings_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule that targets an already-muted finding.
|
||||
"""
|
||||
tenant_id = findings_fixture[1].tenant_id
|
||||
already_muted_finding = findings_fixture[1]
|
||||
|
||||
def test_does_not_cross_tenant_boundary(
|
||||
self, tenants_fixture, provider_factory, create_test_user
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
other_tenant = tenants_fixture[2]
|
||||
other_provider = provider_factory(tenant=other_tenant)
|
||||
other_scan = Scan.objects.create(
|
||||
tenant_id=other_tenant.id,
|
||||
provider=other_provider,
|
||||
name="Other tenant scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC),
|
||||
completed_at=datetime.now(UTC),
|
||||
)
|
||||
other_finding = _create_finding(other_scan, "tenant-isolated-uid")
|
||||
mute_rule = _create_mute_rule(tenant.id, create_test_user, [other_finding.uid])
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(tenant.id), str(mute_rule.id), [str(other_provider.id)]
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Already Muted Rule",
|
||||
reason="Testing already muted findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[already_muted_finding.uid],
|
||||
)
|
||||
|
||||
other_finding.refresh_from_db()
|
||||
assert other_finding.muted is False
|
||||
assert result["scan_ids"] == []
|
||||
return mute_rule
|
||||
|
||||
def test_nonexistent_rule_raises(self, tenants_fixture):
|
||||
with pytest.raises(MuteRule.DoesNotExist):
|
||||
mute_findings_in_latest_scans(str(tenants_fixture[0].id), str(uuid4()), [])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReconcileScanMuteRules:
|
||||
def test_applies_only_enabled_rules_to_requested_scan(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_mixed_findings(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule with a mix of muted and unmuted findings.
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
active_finding = _create_finding(scan, "active-rule-uid")
|
||||
disabled_finding = _create_finding(scan, "disabled-rule-uid")
|
||||
active_rule = _create_mute_rule(
|
||||
scan.tenant_id, create_test_user, [active_finding.uid]
|
||||
)
|
||||
_create_mute_rule(
|
||||
scan.tenant_id,
|
||||
create_test_user,
|
||||
[disabled_finding.uid],
|
||||
enabled=False,
|
||||
)
|
||||
older_scan = Scan.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
provider=scan.provider,
|
||||
name="Older matching scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC) - timedelta(days=1),
|
||||
completed_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
older_finding = _create_finding(older_scan, active_finding.uid)
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
result = reconcile_scan_mute_rules(str(scan.tenant_id), str(scan.id))
|
||||
# Create 3 unmuted findings
|
||||
unmuted_uids = []
|
||||
for i in range(3):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"unmuted_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Unmuted status {i}",
|
||||
impact=Severity.medium,
|
||||
severity=Severity.medium,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.medium,
|
||||
"severity": Severity.medium,
|
||||
},
|
||||
check_id=f"unmuted_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"unmuted_check_{i}",
|
||||
"Description": f"Unmuted description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
unmuted_uids.append(finding.uid)
|
||||
|
||||
active_finding.refresh_from_db()
|
||||
disabled_finding.refresh_from_db()
|
||||
older_finding.refresh_from_db()
|
||||
assert active_finding.muted is True
|
||||
assert active_finding.muted_at == active_rule.inserted_at
|
||||
assert disabled_finding.muted is False
|
||||
assert older_finding.muted is False
|
||||
assert result == {"findings_muted": 1, "scan_id": str(scan.id)}
|
||||
# Create 2 already muted findings
|
||||
muted_uids = []
|
||||
for i in range(2):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"muted_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Muted status {i}",
|
||||
impact=Severity.low,
|
||||
severity=Severity.low,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.low,
|
||||
"severity": Severity.low,
|
||||
},
|
||||
check_id=f"muted_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"muted_check_{i}",
|
||||
"Description": f"Muted description {i}",
|
||||
},
|
||||
muted=True,
|
||||
muted_at=datetime.now(UTC),
|
||||
muted_reason="Already muted",
|
||||
)
|
||||
muted_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
all_uids = unmuted_uids + muted_uids
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Mixed Findings Rule",
|
||||
reason="Testing mixed muted/unmuted findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=all_uids,
|
||||
)
|
||||
|
||||
return mute_rule, unmuted_uids, muted_uids
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_batch_test(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create enough findings to test batch processing (>1000 for default batch size).
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 1500 findings to exceed default batch size of 1000
|
||||
finding_uids = []
|
||||
for i in range(1500):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"batch_test_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Batch test status {i}",
|
||||
impact=Severity.critical,
|
||||
severity=Severity.critical,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.critical,
|
||||
"severity": Severity.critical,
|
||||
},
|
||||
check_id=f"batch_test_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"batch_test_check_{i}",
|
||||
"Description": f"Batch test description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
finding_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Batch Processing Rule",
|
||||
reason="Testing batch processing functionality",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=finding_uids,
|
||||
)
|
||||
|
||||
return mute_rule, finding_uids
|
||||
|
||||
def test_mute_historical_findings_single_finding(
|
||||
self, mute_rule_with_findings, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test muting a single historical finding.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[0]
|
||||
|
||||
# Ensure the finding is not muted before execution
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is False
|
||||
assert finding.muted_at is None
|
||||
assert finding.muted_reason is None
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 1
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the finding was muted
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_multiple_findings(
|
||||
self, mute_rule_multiple_findings
|
||||
):
|
||||
"""
|
||||
Test muting multiple historical findings.
|
||||
"""
|
||||
mute_rule, finding_uids = mute_rule_multiple_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Verify all findings are unmuted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
assert findings.count() == 5
|
||||
for finding in findings:
|
||||
assert finding.muted is False
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 5
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify all findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_already_muted(
|
||||
self, mute_rule_already_muted, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test that already-muted findings are not counted or updated.
|
||||
"""
|
||||
mute_rule = mute_rule_already_muted
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[1]
|
||||
|
||||
# Verify the finding is already muted
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
original_muted_at = finding.muted_at
|
||||
original_muted_reason = finding.muted_reason
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the finding's mute status did not change
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == original_muted_at
|
||||
assert finding.muted_reason == original_muted_reason
|
||||
|
||||
def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings):
|
||||
"""
|
||||
Test muting when some findings are already muted and others are not.
|
||||
"""
|
||||
mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify only unmuted findings were counted
|
||||
assert result["findings_muted"] == 3
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify unmuted findings are now muted
|
||||
unmuted_findings = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=unmuted_uids
|
||||
)
|
||||
for finding in unmuted_findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
# Verify already-muted findings remained unchanged
|
||||
already_muted_findings = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=muted_uids
|
||||
)
|
||||
for finding in already_muted_findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_reason == "Already muted"
|
||||
|
||||
def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture):
|
||||
"""
|
||||
Test that a nonexistent mute rule raises ObjectDoesNotExist.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
nonexistent_rule_id = str(uuid4())
|
||||
|
||||
with pytest.raises(ObjectDoesNotExist):
|
||||
mute_historical_findings(tenant_id, nonexistent_rule_id)
|
||||
|
||||
def test_mute_historical_findings_no_matching_findings(
|
||||
self, tenants_fixture, test_user
|
||||
):
|
||||
"""
|
||||
Test muting when no findings match the rule's UIDs.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
|
||||
# Create a mute rule with non-existent finding UIDs
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test No Match Rule",
|
||||
reason="Testing no matching findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[
|
||||
"nonexistent_uid_1",
|
||||
"nonexistent_uid_2",
|
||||
"nonexistent_uid_3",
|
||||
],
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test):
|
||||
"""
|
||||
Test that large numbers of findings are processed in batches correctly.
|
||||
"""
|
||||
mute_rule, finding_uids = mute_rule_batch_test
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Verify all findings exist and are unmuted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
assert findings.count() == 1500
|
||||
for finding in findings:
|
||||
assert finding.muted is False
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 1500
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify all findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_preserves_muted_at_timestamp(
|
||||
self, mute_rule_with_findings, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test that muted_at is set to the rule's inserted_at, not the current time.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[0]
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify the finding was muted
|
||||
assert result["findings_muted"] == 1
|
||||
|
||||
# Verify muted_at matches the rule's inserted_at timestamp
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_at is not None
|
||||
|
||||
def test_mute_historical_findings_partial_match(self, scans_fixture, test_user):
|
||||
"""
|
||||
Test muting when only some of the rule's UIDs exist as findings.
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = str(scan.tenant_id)
|
||||
|
||||
# Create 3 findings
|
||||
existing_uids = []
|
||||
for i in range(3):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"partial_match_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Partial match status {i}",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.high,
|
||||
"severity": Severity.high,
|
||||
},
|
||||
check_id=f"partial_match_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"partial_match_check_{i}",
|
||||
"Description": f"Partial match description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
existing_uids.append(finding.uid)
|
||||
|
||||
# Create a mute rule with both existing and non-existing UIDs
|
||||
all_uids = existing_uids + [
|
||||
"nonexistent_uid_1",
|
||||
"nonexistent_uid_2",
|
||||
]
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Partial Match Rule",
|
||||
reason="Testing partial matching",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=all_uids,
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify only existing findings were muted
|
||||
assert result["findings_muted"] == 3
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the existing findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids)
|
||||
assert findings.count() == 3
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user):
|
||||
"""
|
||||
Test muting when the rule has an empty finding_uids array.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
|
||||
# Create a mute rule with empty finding_uids
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Empty UIDs Rule",
|
||||
reason="Testing empty UIDs",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[],
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
def test_mute_historical_findings_return_format(self, mute_rule_with_findings):
|
||||
"""
|
||||
Test that the return value has the correct format and fields.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value structure
|
||||
assert isinstance(result, dict)
|
||||
assert "findings_muted" in result
|
||||
assert "rule_id" in result
|
||||
assert isinstance(result["findings_muted"], int)
|
||||
assert isinstance(result["rule_id"], str)
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import MutableMapping
|
||||
from contextlib import contextmanager
|
||||
@@ -9,7 +10,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import ProviderConnectionError, ProviderDeletedException
|
||||
from api.models import (
|
||||
Finding,
|
||||
@@ -2795,167 +2795,6 @@ class TestCreateComplianceRequirements:
|
||||
|
||||
assert count_after_first > 0
|
||||
assert count_after_second == count_after_first
|
||||
with rls_transaction(tenant_id):
|
||||
row_versions = {
|
||||
row_id.version
|
||||
for row_id in ComplianceRequirementOverview.objects.filter(
|
||||
scan_id=scan_id
|
||||
).values_list("id", flat=True)
|
||||
}
|
||||
assert row_versions == {7}
|
||||
|
||||
def test_create_compliance_requirements_threatscore_counts_from_template(
|
||||
self,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
findings_fixture,
|
||||
):
|
||||
"""ThreatScore finding counts are derived from the template mapping,
|
||||
not from each finding's stored ``compliance`` payload."""
|
||||
from api.models import ComplianceRequirementOverview
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template:
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
scan_id = str(scans_fixture[0].id)
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"prowler_threatscore_aws": {
|
||||
"framework": "ProwlerThreatScore",
|
||||
"version": "1.0",
|
||||
"requirements": {
|
||||
"1.1.1": {
|
||||
"description": "ThreatScore requirement",
|
||||
"checks": {"test_check_id": None},
|
||||
},
|
||||
"1.1.2": {
|
||||
"description": "Unrelated requirement",
|
||||
"checks": {"other_check_id": None},
|
||||
},
|
||||
},
|
||||
},
|
||||
"other_framework": {
|
||||
"framework": "Other",
|
||||
"version": "2.0",
|
||||
"requirements": {
|
||||
"a": {
|
||||
"description": "Same check, other framework",
|
||||
"checks": {"test_check_id": None},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
create_compliance_requirements(tenant_id, scan_id)
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
counted = sum(
|
||||
len(finding.resource_regions or [])
|
||||
for finding in Finding.all_objects.filter(
|
||||
scan_id=scan_id,
|
||||
muted=False,
|
||||
status__in=["PASS", "FAIL"],
|
||||
check_id="test_check_id",
|
||||
)
|
||||
)
|
||||
rows = list(
|
||||
ComplianceRequirementOverview.objects.filter(
|
||||
scan_id=scan_id
|
||||
).values_list("compliance_id", "requirement_id", "total_findings")
|
||||
)
|
||||
assert counted > 0
|
||||
assert (
|
||||
sum(
|
||||
total
|
||||
for compliance_id, requirement_id, total in rows
|
||||
if (compliance_id, requirement_id)
|
||||
== ("prowler_threatscore_aws", "1.1.1")
|
||||
)
|
||||
== counted
|
||||
)
|
||||
assert all(
|
||||
total == 0
|
||||
for compliance_id, requirement_id, total in rows
|
||||
if (compliance_id, requirement_id) == ("prowler_threatscore_aws", "1.1.2")
|
||||
)
|
||||
assert all(
|
||||
total == 0
|
||||
for compliance_id, _, total in rows
|
||||
if compliance_id == "other_framework"
|
||||
)
|
||||
|
||||
def test_create_compliance_requirements_rows_across_regions_and_frameworks(
|
||||
self,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
):
|
||||
from api.models import ComplianceRequirementOverview
|
||||
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
scan_id = str(scans_fixture[0].id)
|
||||
check_status_by_region = {
|
||||
"us-east-1": {"check_a": "FAIL", "check_b": "PASS"},
|
||||
"eu-west-1": {"check_a": "PASS"},
|
||||
}
|
||||
template = {
|
||||
"fw_one": {
|
||||
"framework": "One",
|
||||
"version": "1",
|
||||
"requirements": {
|
||||
"r1": {"description": "a", "checks": {"check_a": None}},
|
||||
"r2": {
|
||||
"description": "a+b",
|
||||
"checks": {"check_a": None, "check_b": None},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fw_two": {
|
||||
"framework": "Two",
|
||||
"version": "2",
|
||||
"requirements": {
|
||||
"m1": {"description": "manual", "checks": {}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
patch(
|
||||
"tasks.jobs.scan._aggregate_findings_by_region",
|
||||
return_value=(check_status_by_region, {}),
|
||||
),
|
||||
):
|
||||
mock_compliance_template.__getitem__.return_value = template
|
||||
result = create_compliance_requirements(tenant_id, scan_id)
|
||||
|
||||
assert result["requirements_created"] == 6
|
||||
with rls_transaction(tenant_id):
|
||||
rows = set(
|
||||
ComplianceRequirementOverview.objects.filter(
|
||||
scan_id=scan_id
|
||||
).values_list(
|
||||
"compliance_id",
|
||||
"requirement_id",
|
||||
"region",
|
||||
"requirement_status",
|
||||
"passed_checks",
|
||||
"failed_checks",
|
||||
"total_checks",
|
||||
)
|
||||
)
|
||||
assert rows == {
|
||||
("fw_one", "r1", "us-east-1", "FAIL", 0, 1, 1),
|
||||
("fw_one", "r1", "eu-west-1", "PASS", 1, 0, 1),
|
||||
("fw_one", "r2", "us-east-1", "FAIL", 1, 1, 2),
|
||||
("fw_one", "r2", "eu-west-1", "PASS", 1, 0, 2),
|
||||
("fw_two", "m1", "us-east-1", "MANUAL", 0, 0, 0),
|
||||
("fw_two", "m1", "eu-west-1", "MANUAL", 0, 0, 0),
|
||||
}
|
||||
|
||||
def test_create_compliance_requirements_kubernetes_provider(
|
||||
self,
|
||||
@@ -4884,11 +4723,17 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test function returns correct data structure."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
|
||||
# (check_id, status, resource_regions) tuples
|
||||
finding_rows = [("check1", "FAIL", ["us-east-1"])]
|
||||
threatscore_by_check = {"check1": ["req1", "req2"]}
|
||||
# (check_id, status, resource_regions, compliance) tuples
|
||||
finding_rows = [
|
||||
(
|
||||
"check1",
|
||||
"FAIL",
|
||||
["us-east-1"],
|
||||
{modeled_threatscore_compliance_id: ["req1", "req2"]},
|
||||
)
|
||||
]
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -4902,16 +4747,13 @@ class TestAggregateFindingsByRegion:
|
||||
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
)
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions"
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -4932,14 +4774,13 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test that FAIL status takes priority over other statuses."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
|
||||
# Same check/region: PASS first, then FAIL — FAIL must win
|
||||
finding_rows = [
|
||||
("check1", "PASS", ["us-east-1"]),
|
||||
("check1", "FAIL", ["us-east-1"]),
|
||||
("check1", "PASS", ["us-east-1"], {}),
|
||||
("check1", "FAIL", ["us-east-1"], {}),
|
||||
]
|
||||
threatscore_by_check = {}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -4952,15 +4793,12 @@ class TestAggregateFindingsByRegion:
|
||||
mock_findings_filter.return_value = mock_queryset
|
||||
|
||||
check_status_by_region, _ = _aggregate_findings_by_region(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions"
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -4975,9 +4813,8 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test that muted findings are filtered out (muted=False in query)."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
|
||||
threatscore_by_check = {}
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
mock_queryset.iterator.return_value = []
|
||||
@@ -4989,15 +4826,12 @@ class TestAggregateFindingsByRegion:
|
||||
mock_findings_filter.return_value = mock_queryset
|
||||
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions"
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -5017,14 +4851,23 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test that ThreatScore compliance counts are processed correctly."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
|
||||
# PASS and FAIL findings mapped to the same ThreatScore requirement
|
||||
finding_rows = [
|
||||
("check1", "PASS", ["us-east-1"]),
|
||||
("check2", "FAIL", ["us-east-1"]),
|
||||
(
|
||||
"check1",
|
||||
"PASS",
|
||||
["us-east-1"],
|
||||
{modeled_threatscore_compliance_id: ["req1"]},
|
||||
),
|
||||
(
|
||||
"check2",
|
||||
"FAIL",
|
||||
["us-east-1"],
|
||||
{modeled_threatscore_compliance_id: ["req1"]},
|
||||
),
|
||||
]
|
||||
threatscore_by_check = {"check1": ["req1"], "check2": ["req1"]}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -5037,19 +4880,19 @@ class TestAggregateFindingsByRegion:
|
||||
mock_findings_filter.return_value = mock_queryset
|
||||
|
||||
_, findings_count_by_compliance = _aggregate_findings_by_region(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions"
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
# Verify compliance counts
|
||||
normalized_id = re.sub(
|
||||
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
|
||||
)
|
||||
assert "us-east-1" in findings_count_by_compliance
|
||||
assert normalized_id in findings_count_by_compliance["us-east-1"]
|
||||
assert "req1" in findings_count_by_compliance["us-east-1"][normalized_id]
|
||||
@@ -5066,14 +4909,13 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test aggregation across multiple regions."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
|
||||
# One finding per region
|
||||
finding_rows = [
|
||||
("check1", "FAIL", ["us-east-1"]),
|
||||
("check1", "PASS", ["us-west-2"]),
|
||||
("check1", "FAIL", ["us-east-1"], {}),
|
||||
("check1", "PASS", ["us-west-2"], {}),
|
||||
]
|
||||
threatscore_by_check = {}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -5086,15 +4928,12 @@ class TestAggregateFindingsByRegion:
|
||||
mock_findings_filter.return_value = mock_queryset
|
||||
|
||||
check_status_by_region, _ = _aggregate_findings_by_region(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions"
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -5112,10 +4951,16 @@ class TestAggregateFindingsByRegion:
|
||||
"""A finding with multiple resource_regions is tallied in every region."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
|
||||
finding_rows = [("check1", "FAIL", ["us-east-1", "eu-west-1"])]
|
||||
threatscore_by_check = {"check1": ["req1"]}
|
||||
finding_rows = [
|
||||
(
|
||||
"check1",
|
||||
"FAIL",
|
||||
["us-east-1", "eu-west-1"],
|
||||
{modeled_threatscore_compliance_id: ["req1"]},
|
||||
)
|
||||
]
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -5129,19 +4974,19 @@ class TestAggregateFindingsByRegion:
|
||||
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
)
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions"
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
normalized_id = re.sub(
|
||||
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
|
||||
)
|
||||
for region in ("us-east-1", "eu-west-1"):
|
||||
assert check_status_by_region[region]["check1"] == "FAIL"
|
||||
req_stats = findings_count_by_compliance[region][normalized_id]["req1"]
|
||||
@@ -5155,13 +5000,12 @@ class TestAggregateFindingsByRegion:
|
||||
"""A finding with no denormalized regions contributes nothing."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
|
||||
finding_rows = [
|
||||
("check1", "FAIL", []),
|
||||
("check2", "PASS", None),
|
||||
("check1", "FAIL", [], {modeled_threatscore_compliance_id: ["req1"]}),
|
||||
("check2", "PASS", None, {}),
|
||||
]
|
||||
threatscore_by_check = {"check1": ["req1"]}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -5175,16 +5019,13 @@ class TestAggregateFindingsByRegion:
|
||||
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
)
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions"
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -5199,9 +5040,8 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test with no findings - should return empty dicts."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
|
||||
threatscore_by_check = {}
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
mock_queryset.iterator.return_value = []
|
||||
@@ -5214,16 +5054,13 @@ class TestAggregateFindingsByRegion:
|
||||
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
)
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions"
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -33,10 +33,10 @@ from tasks.tasks import (
|
||||
check_integrations_task,
|
||||
check_lighthouse_provider_connection_task,
|
||||
generate_outputs_task,
|
||||
mute_findings_in_latest_scans_task,
|
||||
perform_attack_paths_scan_task,
|
||||
perform_scan_task,
|
||||
perform_scheduled_scan_task,
|
||||
reaggregate_all_finding_group_summaries_task,
|
||||
refresh_lighthouse_provider_models_task,
|
||||
s3_integration_task,
|
||||
security_hub_integration_task,
|
||||
@@ -2959,7 +2959,6 @@ class TestPerformScheduledScanTask:
|
||||
with (
|
||||
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
|
||||
patch("tasks.tasks._perform_scan_complete_tasks"),
|
||||
patch("tasks.tasks.reconcile_scan_mute_rules") as mock_reconcile,
|
||||
self._override_task_request(perform_scheduled_scan_task, id=task_id),
|
||||
):
|
||||
perform_scheduled_scan_task.run(
|
||||
@@ -2983,13 +2982,6 @@ class TestPerformScheduledScanTask:
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
completed_scan = Scan.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.SCHEDULED,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
mock_reconcile.assert_called_once_with(str(tenant.id), str(completed_scan.id))
|
||||
assert (
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant.id,
|
||||
@@ -3184,10 +3176,7 @@ class TestPerformScanTask:
|
||||
task=queued_task,
|
||||
)
|
||||
|
||||
events = []
|
||||
|
||||
def _complete_scan(tenant_id, scan_id, provider_id, checks_to_execute=None):
|
||||
events.append("scan")
|
||||
scan_instance = Scan.objects.get(id=scan_id)
|
||||
scan_instance.state = StateChoices.COMPLETED
|
||||
scan_instance.save()
|
||||
@@ -3195,14 +3184,7 @@ class TestPerformScanTask:
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
|
||||
patch(
|
||||
"tasks.tasks.reconcile_scan_mute_rules",
|
||||
side_effect=lambda *_args: events.append("reconcile"),
|
||||
),
|
||||
patch(
|
||||
"tasks.tasks._perform_scan_complete_tasks",
|
||||
side_effect=lambda *_args: events.append("summaries"),
|
||||
),
|
||||
patch("tasks.tasks._perform_scan_complete_tasks"),
|
||||
patch("tasks.tasks.perform_scan_task.apply_async") as mock_apply_async,
|
||||
):
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
@@ -3214,7 +3196,6 @@ class TestPerformScanTask:
|
||||
|
||||
queued_task_result.refresh_from_db()
|
||||
assert result == {"status": "ok"}
|
||||
assert events == ["scan", "reconcile", "summaries"]
|
||||
assert queued_task_result.status == states.PENDING
|
||||
mock_apply_async.assert_called_once_with(
|
||||
kwargs={
|
||||
@@ -3260,7 +3241,10 @@ class TestPerformScanTask:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMuteFindingsInLatestScansTask:
|
||||
class TestReaggregateAllFindingGroupSummaries:
|
||||
def setup_method(self):
|
||||
self.tenant_id = str(uuid.uuid4())
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task")
|
||||
@@ -3269,10 +3253,10 @@ class TestMuteFindingsInLatestScansTask:
|
||||
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_daily_severity_task")
|
||||
@patch("tasks.tasks.perform_scan_summary_task")
|
||||
@patch("tasks.tasks.mute_findings_in_latest_scans")
|
||||
def test_reaggregates_only_changed_scans(
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_dispatches_subtasks_for_each_provider_per_day(
|
||||
self,
|
||||
mock_mute_findings,
|
||||
mock_scan_filter,
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
@@ -3281,36 +3265,49 @@ class TestMuteFindingsInLatestScansTask:
|
||||
mock_attack_surface_task,
|
||||
mock_group,
|
||||
mock_chain,
|
||||
tenants_fixture,
|
||||
):
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
mute_rule_id = str(uuid.uuid4())
|
||||
provider_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
scan_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
result = {
|
||||
"findings_muted": 2,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": scan_ids,
|
||||
}
|
||||
mock_mute_findings.return_value = result
|
||||
provider_id_1 = uuid.uuid4()
|
||||
provider_id_2 = uuid.uuid4()
|
||||
scan_id_today_p1 = uuid.uuid4()
|
||||
scan_id_yesterday_p1 = uuid.uuid4()
|
||||
scan_id_today_p2 = uuid.uuid4()
|
||||
today = datetime.now(tz=UTC)
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
mock_outer_group_result = MagicMock()
|
||||
# The first `group()` call wraps the inner parallel step; subsequent
|
||||
# calls wrap the outer per-scan generator.
|
||||
mock_group.side_effect = lambda *args, **kwargs: (
|
||||
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
|
||||
mock_outer_group_result,
|
||||
)[1]
|
||||
|
||||
task_result = mute_findings_in_latest_scans_task(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
|
||||
{
|
||||
"id": scan_id_today_p1,
|
||||
"completed_at": today,
|
||||
"provider_id": provider_id_1,
|
||||
},
|
||||
{
|
||||
"id": scan_id_today_p2,
|
||||
"completed_at": today,
|
||||
"provider_id": provider_id_2,
|
||||
},
|
||||
{
|
||||
"id": scan_id_yesterday_p1,
|
||||
"completed_at": yesterday,
|
||||
"provider_id": provider_id_1,
|
||||
},
|
||||
]
|
||||
|
||||
assert task_result == result
|
||||
mock_mute_findings.assert_called_once_with(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 3}
|
||||
expected_scan_ids = {
|
||||
str(scan_id_today_p1),
|
||||
str(scan_id_today_p2),
|
||||
str(scan_id_yesterday_p1),
|
||||
}
|
||||
for task_mock in (
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
@@ -3319,35 +3316,93 @@ class TestMuteFindingsInLatestScansTask:
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
):
|
||||
assert task_mock.si.call_count == 2
|
||||
assert {
|
||||
assert task_mock.si.call_count == 3
|
||||
dispatched = {
|
||||
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
|
||||
} == set(scan_ids)
|
||||
assert mock_chain.call_count == 2
|
||||
}
|
||||
assert dispatched == expected_scan_ids
|
||||
for call in task_mock.si.call_args_list:
|
||||
assert call.kwargs["tenant_id"] == self.tenant_id
|
||||
assert mock_chain.call_count == 3
|
||||
mock_outer_group_result.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.mute_findings_in_latest_scans")
|
||||
def test_skips_reaggregation_when_no_scan_changed(
|
||||
self, mock_mute_findings, mock_group, mock_chain, tenants_fixture
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task")
|
||||
@patch("tasks.tasks.aggregate_scan_category_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_scan_resource_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_daily_severity_task")
|
||||
@patch("tasks.tasks.perform_scan_summary_task")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_dedupes_scans_to_latest_per_provider_per_day(
|
||||
self,
|
||||
mock_scan_filter,
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
mock_resource_group_task,
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
mock_group,
|
||||
mock_chain,
|
||||
):
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
mute_rule_id = str(uuid.uuid4())
|
||||
result = {
|
||||
"findings_muted": 0,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": [],
|
||||
}
|
||||
mock_mute_findings.return_value = result
|
||||
"""When several scans run on the same day for the same provider, only
|
||||
the latest one is dispatched (matching the daily summary unique key)."""
|
||||
provider_id = uuid.uuid4()
|
||||
latest_scan_today = uuid.uuid4()
|
||||
earlier_scan_today = uuid.uuid4()
|
||||
today_late = datetime.now(tz=UTC)
|
||||
today_early = today_late - timedelta(hours=4)
|
||||
|
||||
task_result = mute_findings_in_latest_scans_task(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=[],
|
||||
)
|
||||
mock_outer_group_result = MagicMock()
|
||||
mock_group.side_effect = lambda *args, **kwargs: (
|
||||
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
|
||||
mock_outer_group_result,
|
||||
)[1]
|
||||
|
||||
assert task_result == result
|
||||
# Returned ordered by `-completed_at`, so the most recent comes first.
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
|
||||
{
|
||||
"id": latest_scan_today,
|
||||
"completed_at": today_late,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
{
|
||||
"id": earlier_scan_today,
|
||||
"completed_at": today_early,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
]
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 1}
|
||||
for task_mock in (
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
mock_resource_group_task,
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
):
|
||||
task_mock.si.assert_called_once_with(
|
||||
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
|
||||
)
|
||||
mock_chain.assert_called_once()
|
||||
mock_outer_group_result.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_no_completed_scans_skips_dispatch(
|
||||
self, mock_scan_filter, mock_group, mock_chain
|
||||
):
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = []
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 0}
|
||||
mock_group.assert_not_called()
|
||||
mock_chain.assert_not_called()
|
||||
|
||||
|
||||
@@ -4835,8 +4835,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.41.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.40#4d13e8432e57289a188c2a12200dcd8437f35543" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4938,7 +4938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.44.0"
|
||||
version = "1.41.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -5038,7 +5038,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.40" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
|
||||
@@ -4,284 +4,6 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.42.0" description="September 11, 2026">
|
||||
### ☁️ AWS — ISO Partitions
|
||||
|
||||
Prowler now resolves regions and services for the AWS ISO partitions (`aws-iso`, `aws-iso-b`, `aws-iso-e` and `aws-iso-f`) the same way it does for the commercial, China, European Sovereign Cloud and GovCloud partitions. The region matrix is filled from the endpoint metadata bundled with botocore, which needs no credentials or network access, so it covers partitions that are air-gapped from the internet. Scanning them no longer requires a hand-edited `aws_regions_by_service.json`: ISO regions such as `us-isob-east-1` are accepted by `--region` and `--excluded-region`.
|
||||
|
||||
Deployments that declare `PROWLER_AWS_PARTITION` also keep their bootstrap STS calls in the configured region when it belongs to that partition. An install in `us-gov-west-1` that reaches AWS only through its own VPC endpoints is no longer sent to `us-gov-east-1`, where the connection check and the scan used to time out.
|
||||
|
||||
Read more in the [AWS Regions and Partitions documentation](https://docs.prowler.com/user-guide/providers/aws/regions-and-partitions).
|
||||
|
||||
### ⏱️ AWS — Configurable Timeouts for Restricted Networks
|
||||
|
||||
Scans from networks with restricted egress (VPC endpoints for only some services, GovCloud or private deployments) could take hours: Boto3 waits 60 seconds to connect by default and retries connection errors, so every service without a reachable endpoint cost up to four 60-second attempts in every region. Prowler now lowers the default connect timeout to 10 seconds, keeps the read timeout at 60 seconds, and exposes both through `--aws-connect-timeout` and `--aws-read-timeout`, or through the `PROWLER_AWS_BOTO3_CONNECT_TIMEOUT` and `PROWLER_AWS_BOTO3_READ_TIMEOUT` environment variables for deployments without a CLI. `--aws-retries-max-attempts 0` now disables retries instead of silently falling back to three, leaving a single attempt per call.
|
||||
|
||||
Read more in the [Boto3 configuration documentation](https://docs.prowler.com/user-guide/providers/aws/boto3-configuration).
|
||||
|
||||
### 🐳 Image Provider — Reusable Vulnerability Database
|
||||
|
||||
The Image provider now honors `TRIVY_CACHE_DIR`. When the variable names a directory, Trivy keeps its vulnerability database there and Prowler leaves the directory in place after the scan, so the database is downloaded once instead of on every scan. Hosts without internet access can now scan images by pointing `TRIVY_CACHE_DIR` at a pre-populated database and setting `TRIVY_SKIP_DB_UPDATE=true`. Without the variable, the temporary cache is created and removed as before.
|
||||
|
||||
Read more in the [Image provider documentation](https://docs.prowler.com/user-guide/providers/image/getting-started-image#vulnerability-database-cache).
|
||||
|
||||
### 🎫 Jira Integration — Faster Connection Test
|
||||
|
||||
Testing a Jira integration no longer reports a false failure on accounts with many projects. The connection test fetched the issue types of every project one request at a time, which could outlast the wait in the UI even when the check was about to succeed. Issue types are now fetched concurrently, a project whose issue types the integration user cannot see is no longer logged as an error, and the Integrations page keeps following the connection test instead of giving up after about a minute.
|
||||
|
||||
Read more in the [Jira integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-jira-integration).
|
||||
|
||||
### 📚 Compliance — Catalog Integrity Fixes
|
||||
|
||||
A new integrity test runs over every compliance framework, asserting unique requirement IDs, no check listed twice within a requirement, and that every referenced check exists for its provider. The fixes it drove span 42 frameworks across AWS, Azure, GCP, GitHub, Kubernetes and Microsoft 365:
|
||||
|
||||
- **Duplicate requirement IDs:** identical copies are removed, and distinct requirements that shared an ID get their own, such as `1.10` in CIS AWS 5.0 and `rc_rp_1` for RC.RP-1 in NIST CSF 1.1. In Prowler ThreatScore for Azure, SQL auditing retention moves from `3.2.1` to `3.2.4`, and requirement `1.2.1` of Prowler ThreatScore for GCP now points to `iam_sa_no_user_managed_keys`.
|
||||
- **Stale check references:** checks that no longer exist are replaced with their current name when there is a direct equivalent, or removed so the requirement reports as manual. Most of these were in the FedRAMP 20x KSI frameworks.
|
||||
|
||||
Renamed requirement IDs appear as new requirements for scans run after the upgrade.
|
||||
|
||||
The compliance overview task that runs after every scan is also faster: ThreatScore mappings are read once from the compliance template instead of from every finding, and rows are inserted with time-ordered `uuid7` IDs grouped by framework and requirement.
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
`rolesanywhere_profile_restricts_session_permissions`, `iam_role_service_trust_restricts_source_to_account` and `codebuild_project_uses_allowed_github_organizations` no longer crash with `TypeError` when the scanning role is denied `iam:ListRoles`, which dropped every finding of those checks for the account. Without the role inventory, an enabled IAM Roles Anywhere profile without session scoping reports `MANUAL`, and CodeBuild projects whose service role cannot be resolved are skipped.
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- `next` upgraded to 16.3.3 in the UI, patching unauthenticated remote code execution through AVIF image optimization ([GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4)) and on Windows-hosted servers ([GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36)).
|
||||
- `sharp` upgraded to 0.35.4 in the UI, patching libheif image-decoding vulnerabilities ([GHSA-rgj7-g3m4-5g8c](https://github.com/advisories/GHSA-rgj7-g3m4-5g8c)).
|
||||
- `nanoid`, `js-yaml` and `postcss`, plus eleven transitive UI dependencies, upgraded to patched versions, resolving 40 npm audit advisories (21 high, 15 moderate, 4 low).
|
||||
- `libuuid` upgraded to 2.41.6-r1 in the MCP Server image, patching CVE-2026-53612, CVE-2026-53613, CVE-2026-53614, CVE-2026-76642, CVE-2026-78408 and CVE-2026-78410.
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.42.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.41.0" description="September 2, 2026">
|
||||
### 📥 Scans — Import Findings from the Browser
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Findings produced outside the platform, by the Prowler CLI or a CI pipeline, can now be brought into the app without leaving the browser. The Scans page gains an "Import Findings" dialog that takes a Prowler `.ocsf.json` report by drag-and-drop or file picker, hands it to the ingestion API and tracks the job to completion, reporting how many records were processed and how many were invalid. Files that are not a `.ocsf.json` report, or are empty, are refused before any upload starts, and a rejected upload or a failed status poll can be retried in place. The dialog is available to roles holding the Manage Ingestions permission.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Read more in the [Import Findings documentation](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-ui).
|
||||
|
||||
### 🎫 Jira Integration — Finding Reference in Every Issue
|
||||
|
||||
Every Jira issue created from a finding now carries a stable reference back to it. Issues are labeled `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>` and `prowler-finding-<finding-uid>`, so they can be filtered, searched with JQL or matched by automation; labels are sanitized to Jira's limits so a long or unusual value never blocks issue creation. The issue also links back to the finding in Prowler, filtered by its UID so the link keeps working after later scans, and names the Prowler organization that sent it. Prowler Cloud always includes the link; Prowler Local Server enables it by setting `DJANGO_UI_BASE_URL` in the API environment.
|
||||
|
||||
Read more in the [Jira integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-jira-integration).
|
||||
|
||||
### 📚 Compliance — CIS Google Workspace Foundations Benchmark v1.4.0
|
||||
|
||||
Prowler now ships the CIS Google Workspace Foundations Benchmark v1.4.0. Alongside the new framework, the Google Workspace checks mapped to CIS were reworked to evaluate the benchmark's full audit procedure instead of a single condition, so Gmail spoofing actions, 2-Step Verification, password expiration and alert severity left on Google's defaults no longer pass. Expect new `FAIL` findings on domains that rely on those defaults. Three accuracy fixes also land:
|
||||
|
||||
- `security_2sv_enforced` and `security_2sv_hardware_keys_admins` report `MANUAL` instead of judging domain-wide values that a group or a sub-organizational unit overrides; a domain-wide failure is still reported as such, with the override noted.
|
||||
- `rules_*_alert_configured` no longer passes a rule whose delivery to the alert center is disabled.
|
||||
- `security_password_policy_strong` no longer fails a domain that never touched the password strength setting, since Google enforces strong passwords by default.
|
||||
|
||||
`security_login_challenges_configured` was unmapped from CIS Google Workspace requirement 4.1.4.1 (Post-SSO verification) and `security_2sv_enforced` from CISA SCuBA `GWS.COMMONCONTROLS.1.1` (phishing-resistant MFA), because neither check can prove what those requirements ask for.
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
Ten new AWS checks land in this release, eight of them contributed by @tamg-aws. Thank you!
|
||||
|
||||
#### Amazon Bedrock AgentCore
|
||||
|
||||
- `iam_policy_passrole_to_bedrock_agentcore_restricted` flags customer-managed IAM policies that allow `iam:PassRole` over every role where the passed role can reach Bedrock AgentCore, so any principal holding the policy could run agent code under any role in the account.
|
||||
- `iam_policy_no_agentcore_workload_access_token_wildcard` flags customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own.
|
||||
- `cloudwatch_log_group_agentcore_data_protection_policy_enabled` verifies that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy. The log group prefixes are configurable through `agentcore_log_group_name_prefixes` in `config.yaml`.
|
||||
|
||||
#### Amazon GuardDuty
|
||||
|
||||
- `guardduty_runtime_monitoring_enabled` flags detectors without unified Runtime Monitoring, the only feature that covers Amazon EC2 instances and Amazon ECS on AWS Fargate tasks in addition to Amazon EKS.
|
||||
- `guardduty_ai_protection_enabled` flags detectors without AI Protection, which analyzes CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI. A detector that does not report the feature is `MANUAL` rather than `FAIL`.
|
||||
|
||||
`guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS.
|
||||
|
||||
#### Amazon ECR and EKS
|
||||
|
||||
- `ecr_registry_enhanced_scanning_enabled` verifies that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, reporting `MANUAL` when the registry scanning configuration cannot be read.
|
||||
- `eks_cluster_vpc_cni_network_policy_enforced` flags EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, reporting `MANUAL` where the EKS API cannot show the setting.
|
||||
|
||||
#### AWS IAM, Elastic Beanstalk and MemoryDB
|
||||
|
||||
- `iam_role_service_trust_restricts_source_to_account` flags IAM roles whose trust policy lets an AWS service principal assume the role without confining the request to a specific source account, including trust policies that `iam_role_cross_service_confused_deputy_prevention` does not evaluate.
|
||||
- `elasticbeanstalk_environment_no_secrets_in_configuration` scans the option settings of every Elastic Beanstalk environment for hardcoded secrets. Thanks to @haneul-24!
|
||||
- `memorydb_cluster_in_transit_encryption_enabled` verifies that MemoryDB clusters have in-transit encryption (TLS) enabled. Thanks to @UTKARSH698!
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 🐳 Image Provider — On-Premises Registries
|
||||
|
||||
Scanning registries that live on private networks is now supported end to end. `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` takes a comma-separated list of IPs and CIDRs the provider may reach, while every other non-public address, including link-local and loopback, stays blocked by the SSRF guard. Authentication negotiation is also more resilient: the provider falls back to Basic when a registry such as Harbor rejects the negotiated bearer token, and switches to a bearer token when the server answers a Basic or anonymous request with a Bearer challenge. `--registry-insecure` now propagates to Trivy through `TRIVY_INSECURE`, so images behind self-signed certificates can be pulled and scanned, not just enumerated. The flag now disables certificate validation for the image pull too, so keep it for trusted internal registries only.
|
||||
|
||||
Registry scans also skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations), no longer abort the whole scan when Trivy fails on a single image, and enumerate repositories in parallel instead of one request at a time.
|
||||
|
||||
Read more in the [Image provider documentation](https://docs.prowler.com/user-guide/providers/image/getting-started-image#on-premises-registries-and-private-networks).
|
||||
|
||||
### 🛠️ Prowler MCP Server — Tool Failures Reported as Errors
|
||||
|
||||
Prowler Local Server tools now report a failure as an MCP tool execution error (`isError: true`, with the explanation in `content`) instead of a successful result carrying an `{"error": ...}` object, which clients and models read as a success. The Prowler Documentation and Prowler Hub tools follow the same rule: `prowler_docs_search` no longer reports a failed search as zero matches, `prowler_docs_get_document` no longer reports a failed fetch as a missing page, and `prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now name the provider a check ID actually belongs to instead of reporting it as nonexistent. `prowler_get_compliance_framework_state_details` also rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider.
|
||||
|
||||
Read more in the [Prowler MCP documentation](https://docs.prowler.com/getting-started/products/prowler-mcp).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @tamg-aws: GuardDuty unified Runtime Monitoring and AI Protection checks ([#12564](https://github.com/prowler-cloud/prowler/pull/12564)), EKS VPC CNI network policy check ([#12661](https://github.com/prowler-cloud/prowler/pull/12661)), ECR enhanced scanning check ([#12660](https://github.com/prowler-cloud/prowler/pull/12660)), Bedrock AgentCore IAM and service trust checks ([#12664](https://github.com/prowler-cloud/prowler/pull/12664)), AgentCore log group data protection check ([#12662](https://github.com/prowler-cloud/prowler/pull/12662)), and fixes to ECR scan frequency ([#12560](https://github.com/prowler-cloud/prowler/pull/12560)), CloudWatch metric filters ([#12561](https://github.com/prowler-cloud/prowler/pull/12561)) and SageMaker direct internet access ([#12659](https://github.com/prowler-cloud/prowler/pull/12659))
|
||||
- @haneul-24: AWS `elasticbeanstalk_environment_no_secrets_in_configuration` check ([#12378](https://github.com/prowler-cloud/prowler/pull/12378))
|
||||
- @UTKARSH698: AWS `memorydb_cluster_in_transit_encryption_enabled` check ([#12246](https://github.com/prowler-cloud/prowler/pull/12246))
|
||||
- @ye11oc4t: GitHub repository discovery pagination for unscoped scans ([#12460](https://github.com/prowler-cloud/prowler/pull/12460))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.41.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<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
|
||||
|
||||
@@ -554,7 +276,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 in this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave).
|
||||
Read more about it 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.
|
||||
|
||||
|
||||
@@ -129,42 +129,12 @@ Each check **must** populate the `report.status` and `report.status_extended` fi
|
||||
- Status field: `report.status`
|
||||
- `PASS` – Assigned when the check confirms compliance with the configured value.
|
||||
- `FAIL` – Assigned when the check detects non-compliance with the configured value.
|
||||
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`). This includes the case where Prowler could not retrieve the data needed to evaluate the resource (see below).
|
||||
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`).
|
||||
|
||||
- Status extended field: `report.status_extended`
|
||||
- It **must** end with a period (`.`).
|
||||
- It **must** include the audited service, the resource, and a concise explanation of the check result, for instance: `EC2 AMI ami-0123456789 is not public.`.
|
||||
|
||||
### Permission and Data-Availability Errors Are Not Findings
|
||||
|
||||
A `FAIL` must only be emitted when an insecure condition has actually been detected. A check **must never** report `FAIL` because the underlying API call failed: missing permissions or scopes on the scanning identity, an API that is not enabled, a feature that is not licensed, or data that could not be retrieved are scan-configuration problems, not security issues. Reporting them as `FAIL` surfaces a misleading (and often high-severity) finding to the user and skews compliance scores.
|
||||
|
||||
When the service layer cannot obtain the data a check depends on, the check must:
|
||||
|
||||
1. Emit a single `MANUAL` finding scoped to the widest affected resource (the tenant, account, project or subscription), not one finding per resource. For example, if user registration details cannot be read, emit one tenant-level `MANUAL` instead of one per user.
|
||||
2. Explain in `status_extended` that the check could not be evaluated and what to fix, naming the permission, scope, API or license required, for instance: `Cannot evaluate credential exposure for privileged users: unable to query Microsoft Defender XDR Advanced Hunting. Verify that the ThreatHunting.Read.All permission is granted to the scanning application.`
|
||||
3. Leave the check's severity untouched. Do not override `report.check_metadata.Severity` to hide the problem.
|
||||
|
||||
The service layer must make the distinction possible: log the error and expose it to checks in a way that cannot be confused with a legitimate empty result. Common patterns already used in Prowler are:
|
||||
|
||||
- Defaulting the attribute to `None` (data could not be read) instead of `[]`/`{}` (data was read and is empty), e.g. the `metric_filters is not None` guard in `prowler/providers/aws/services/cloudwatch/lib/metric_filters.py`.
|
||||
- Keeping an availability flag raised on any denied listing, e.g. `logs_client.metric_filters_unavailable` consumed by the AWS CloudWatch metric filter checks.
|
||||
- Keeping an error flag or message next to the data, e.g. `entra_client.user_registration_details_error` in M365 or `*_scan_errors` in AWS Bedrock.
|
||||
- Keeping a set of resources whose lookup failed, e.g. `accessapproval_client.settings_lookup_failed` in GCP.
|
||||
|
||||
Make sure the error branch only captures real access errors. A `404`/not-found response frequently means the feature is simply not configured, which **is** a legitimate `FAIL`; a `403` or an unexpected exception is not. An "API not enabled" error is usually a scan-configuration problem too — **except** when the API's activation is itself the control being audited (e.g. GCP Access Approval: with `accessapproval.googleapis.com` disabled the feature provably cannot be enabled, so a definitive API-disabled state is a legitimate `FAIL`, while an undetermined state stays `MANUAL`).
|
||||
|
||||
```python
|
||||
if <service>_client.<data> is None:
|
||||
report = CheckReport<Provider>(metadata=self.metadata(), resource={})
|
||||
report.resource_name = "<Tenant/Account-level resource>"
|
||||
report.resource_id = "<stable-id>"
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = "Cannot evaluate <requirement>: <data> could not be retrieved. Verify that <permission/API/license> is granted to the scanning identity."
|
||||
findings.append(report)
|
||||
return findings
|
||||
```
|
||||
|
||||
### Prowler's Check Severity Levels
|
||||
|
||||
The severity of each check is defined in the metadata file using the `Severity` field. Severity values are always lowercase and must be one of the predefined categories below.
|
||||
@@ -467,7 +437,6 @@ The metadata structure is enforced in code using a Pydantic model. For reference
|
||||
- Use clear, actionable, and user-friendly language in `status_extended` to explain the result. Always provide information to identify the resource.
|
||||
- Use helper functions/utilities for repeated logic to avoid code duplication. Save them in the `lib` folder of the service.
|
||||
- Handle exceptions gracefully: catch errors per resource, log them, and continue processing other resources.
|
||||
- Never report `FAIL` because data could not be retrieved (missing permissions, API not enabled, feature not licensed). Emit a single `MANUAL` finding explaining what is required instead; see [Permission and Data-Availability Errors Are Not Findings](#permission-and-data-availability-errors-are-not-findings).
|
||||
- Document the check with a class and function level docstring explaining what it does, what it checks, and any caveats or provider-specific behaviors.
|
||||
- Use type hints for the `execute()` method (e.g., `-> list[CheckReport<Provider>]`) for clarity and static analysis.
|
||||
- Ensure checks are efficient; avoid excessive nested loops. If the complexity is high, consider refactoring the check.
|
||||
|
||||
@@ -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 [Visual 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 [Virtual 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 to maximize performance and reduce scan time.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
title: 'Basic Usage'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
## 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.
|
||||
@@ -93,18 +91,6 @@ By default, `prowler` will scan all AWS regions.
|
||||
</Note>
|
||||
See more details about AWS Authentication in the [Authentication Section](/user-guide/providers/aws/authentication) section.
|
||||
|
||||
- **AWS Retrier and Timeout Configuration**
|
||||
|
||||
<VersionBadge version="5.42.0" />
|
||||
|
||||
Tune the Boto3 standard retrier and the endpoint timeouts when AWS throttles the scan or when some endpoints are unreachable from the network Prowler runs in:
|
||||
|
||||
```console
|
||||
prowler aws --aws-retries-max-attempts 5 --aws-connect-timeout 5 --aws-read-timeout 30
|
||||
```
|
||||
|
||||
See the [Boto3 configuration](/user-guide/providers/aws/boto3-configuration) page for defaults and environment variables.
|
||||
|
||||
## Azure
|
||||
|
||||
Azure requires specifying the auth method:
|
||||
|
||||
@@ -128,8 +128,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.42.0"
|
||||
PROWLER_API_VERSION="5.42.0"
|
||||
PROWLER_UI_VERSION="5.39.0"
|
||||
PROWLER_API_VERSION="5.39.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.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 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.
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ The following list includes all the AWS checks with configurable variables that
|
||||
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_actions` | List of Strings | See `config.yaml` |
|
||||
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_minutes` | Integer | `1440` |
|
||||
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_threshold` | Float | `0.2` |
|
||||
| `cloudwatch_log_group_agentcore_data_protection_policy_enabled` | `agentcore_log_group_name_prefixes` | List of Strings | See `config.yaml` |
|
||||
| `cloudwatch_log_group_no_secrets_in_logs` | `secrets_ignore_patterns` | List of Strings | `[]` |
|
||||
| `cloudwatch_log_group_retention_policy_specific_days_enabled` | `log_group_retention_days` | Integer | `365` |
|
||||
| `codebuild_project_no_secrets_in_variables` | `excluded_sensitive_environment_variables` | List of Strings | `[]` |
|
||||
|
||||
@@ -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,7 +43,8 @@ prowler <provider> --categories secrets
|
||||
|
||||
Several checks analyse resources that are exposed to the Internet, these are:
|
||||
|
||||
- apigateway\_restapi\_public
|
||||
1. apigateway\_restapi\_public
|
||||
|
||||
- appstream\_fleet\_default\_internet\_access\_disabled
|
||||
- awslambda\_function\_not\_publicly\_accessible
|
||||
- ec2\_ami\_public
|
||||
@@ -57,6 +58,8 @@ 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
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
---
|
||||
title: "Boto3 Retrier and Timeout Configuration in Prowler"
|
||||
title: "Boto3 Retrier Configuration in Prowler"
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
Prowler's AWS Provider leverages Boto3's [Standard](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html) retry mode to automatically retry client calls to AWS services when encountering errors or exceptions.
|
||||
|
||||
## Timeout Configuration
|
||||
|
||||
<VersionBadge version="5.42.0" />
|
||||
|
||||
Every AWS API call is bounded by two timeouts:
|
||||
|
||||
- Connect timeout: seconds to wait to establish a connection (TCP, proxy tunnel and TLS handshake) to the AWS endpoint. Prowler's default is 10 seconds, configurable via `--aws-connect-timeout 5`.
|
||||
- Read timeout: seconds to wait for a response once connected. Prowler's default is 60 seconds, configurable via `--aws-read-timeout 30`.
|
||||
|
||||
Both timeouts can also be set through environment variables, which is the way to tune them in Prowler Cloud and other deployments without a CLI:
|
||||
|
||||
```console
|
||||
export PROWLER_AWS_BOTO3_CONNECT_TIMEOUT=5
|
||||
export PROWLER_AWS_BOTO3_READ_TIMEOUT=30
|
||||
```
|
||||
|
||||
CLI flags take precedence over the environment variables. Prowler sets both timeouts explicitly, so `AWS_DEFAULTS_MODE` and a `connect_timeout` in `~/.aws/config` are ignored; use the flag or the environment variable instead.
|
||||
|
||||
<Note>
|
||||
Boto3 defaults both timeouts to 60 seconds. In networks with restricted egress (for example VPC endpoints for a subset of services, GovCloud or private deployments), every AWS service without a reachable endpoint used to cost up to 4 attempts × 60 seconds (the first call plus the 3 retries) for each region. Prowler lowers the connect timeout to 10 seconds so unreachable endpoints fail fast; lower it further together with `--aws-retries-max-attempts 0`, which disables retries and leaves a single attempt per call, if a scan still spends most of its time waiting on unreachable services.
|
||||
|
||||
</Note>
|
||||
|
||||
## Retry Behavior Overview
|
||||
|
||||
Boto3's Standard retry mode includes the following mechanisms:
|
||||
|
||||
- Maximum Retry Attempts: Default value set to 3, configurable via the `--aws-retries-max-attempts 5` argument. `0` disables retries.
|
||||
- Maximum Retry Attempts: Default value set to 3, configurable via the `--aws-retries-max-attempts 5` argument.
|
||||
|
||||
- Expanded Error Handling: Retries occur for a comprehensive set of errors.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -21,28 +21,10 @@ When scanning the China (`aws-cn`), European Sovereign Cloud (`aws-eusc`) or Gov
|
||||
|
||||
- Specify the regions to audit within that partition using the `-f/--region` flag.
|
||||
|
||||
- Declare the partition with the `PROWLER_AWS_PARTITION` environment variable, set to `aws`, `aws-cn`, `aws-eusc` or `aws-us-gov`.
|
||||
|
||||
<Note>
|
||||
Refer to: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html#configuring-credentials for more information about the AWS credential configuration.
|
||||
|
||||
</Note>
|
||||
### Declaring the Partition
|
||||
|
||||
`PROWLER_AWS_PARTITION` tells Prowler which partition the scan runs against, without relying on a region being configured:
|
||||
|
||||
```bash
|
||||
export PROWLER_AWS_PARTITION="aws-us-gov"
|
||||
```
|
||||
|
||||
It matters most where nothing else says. Resolving an identity means calling STS before anything is known about the credentials, and with no region configured Prowler would otherwise start from the commercial endpoints. Declaring the partition makes that first call go to the right place, which is the difference between a scan that starts and one that fails on an endpoint the credentials cannot use.
|
||||
|
||||
A region configured for the session still wins when it belongs to the declared partition, so a deployment in `us-gov-west-1` is not sent to `us-gov-east-1`. A region belonging to a different partition is ignored, since a partition that has been declared explicitly is the more deliberate statement of the two.
|
||||
|
||||
<Note>
|
||||
Set it wherever the scan runs. For deployments that scan from containers, that means the environment of the containers doing the scanning, not only the one accepting the request.
|
||||
</Note>
|
||||
|
||||
### Scanning Specific Regions
|
||||
|
||||
To scan a particular AWS region with Prowler, use:
|
||||
|
||||
@@ -96,29 +96,6 @@ Install Trivy using one of the following methods:
|
||||
|
||||
For additional installation methods, see the [Trivy installation guide](https://trivy.dev/latest/getting-started/installation/).
|
||||
|
||||
### Vulnerability Database Cache
|
||||
|
||||
<VersionBadge version="5.42.0" />
|
||||
|
||||
Trivy keeps its vulnerability database in a cache directory. By default Prowler gives it a temporary one and removes it when the scan ends, so the database is downloaded again for every scan.
|
||||
|
||||
Set `TRIVY_CACHE_DIR` to a directory that persists and the database is downloaded once and reused:
|
||||
|
||||
```bash
|
||||
export TRIVY_CACHE_DIR="$HOME/.cache/trivy"
|
||||
prowler image --image <image>
|
||||
```
|
||||
|
||||
Prowler never deletes a directory you supply. Trivy still creates and updates its cache and database files inside it.
|
||||
|
||||
<Note>
|
||||
A host with no internet access needs a pre-populated vulnerability database in a persistent directory, with `TRIVY_CACHE_DIR` pointing at it. Populate the directory on a machine that does have access and copy it across.
|
||||
|
||||
Trivy tries to refresh the database when it considers it stale, and that download fails without network access. Set `TRIVY_SKIP_DB_UPDATE=true` (and `TRIVY_SKIP_JAVA_DB_UPDATE=true` if Java scanning is enabled) so it uses the supplied database as is.
|
||||
|
||||
The database ages. A scan run against an old one reports only the vulnerabilities known when it was built, and nothing in the output says so, so keep track of when it was last refreshed.
|
||||
</Note>
|
||||
|
||||
|
||||
### Supported Scanners
|
||||
|
||||
@@ -329,21 +306,9 @@ prowler image --registry internal-registry.local --registry-insecure
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Skipping TLS verification disables certificate validation for registry connections, including the Trivy image pull (`TRIVY_INSECURE`). Use this flag only for trusted internal registries with self-signed certificates.
|
||||
Skipping TLS verification disables certificate validation for registry connections. Use this flag only for trusted internal registries with self-signed certificates.
|
||||
</Warning>
|
||||
|
||||
#### On-Premises Registries and Private Networks
|
||||
|
||||
<VersionBadge version="5.41.0" />
|
||||
|
||||
By default, Prowler rejects registry-provided URLs (token endpoints, pagination links) that resolve to non-public addresses, as an SSRF defense. On-premises registries live on private networks by definition, so to scan them declare the trusted ranges explicitly:
|
||||
|
||||
```bash
|
||||
export PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS="192.168.65.254/32,10.20.0.0/16"
|
||||
```
|
||||
|
||||
The value is a comma-separated list of IPs and CIDRs. A resolved address inside an allowlisted range is permitted; every other non-public address stays blocked, so link-local (`169.254.169.254`), loopback, and the rest of the internal network remain protected. The variable applies to registry enumeration and to the connection test. Malformed entries fail at startup, and a non-empty allowlist is logged as a relaxed security control. When unset, behavior is unchanged: only public addresses are followed.
|
||||
|
||||
#### Supported Registries
|
||||
|
||||
Registry Scan Mode supports the following registry types:
|
||||
|
||||
@@ -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,18 +124,6 @@ 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:
|
||||
@@ -171,13 +159,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,11 +257,11 @@ 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**:
|
||||
|
||||
**Manage Ingestions:** Submit and manage findings ingestion jobs. Required to upload OCSF scan results from the Scans page, with the `--push-to-cloud` CLI flag or through the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details.
|
||||
**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details.
|
||||
|
||||
**Manage Billing:** Access and manage billing settings, subscription plans, and payment methods.
|
||||
|
||||
|
||||
@@ -58,14 +58,12 @@ Two of these read more broadly than they behave, and both are worth understandin
|
||||
|
||||
On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channels authorized on the integration.** The scope exists so that authorizing a public channel does not also require someone to invite the Prowler app to it first.
|
||||
|
||||
{/* The Prowler UI deep-links to this heading's anchor, so rewording the heading breaks that link. */}
|
||||
|
||||
### Why a Private Channel Is Missing From the Channel List
|
||||
|
||||
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler Cloud` to it in Slack:
|
||||
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler` to it in Slack:
|
||||
|
||||
```text
|
||||
/invite @Prowler Cloud
|
||||
/invite @Prowler
|
||||
```
|
||||
|
||||
That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list.
|
||||
@@ -102,14 +100,14 @@ Prowler posts to the channels authorized on the integration. Several channels ca
|
||||
|
||||

|
||||
|
||||
2. Select one or more channels. A selected private channel keeps the same **Private** marking with the list closed, so the authorized set stays readable at a glance.
|
||||
2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance.
|
||||
3. Click **Save channels**.
|
||||
|
||||
Prowler validates the selection against Slack and derives each channel name itself, so a recorded name can never drift from the channel it belongs to. Once the set is saved, the page reports where Prowler posts and runs the connection check over it.
|
||||
|
||||
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler Cloud` to a private one, then click **Refresh channels**.
|
||||
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**.
|
||||
|
||||
A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler Cloud` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out.
|
||||
A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out.
|
||||
|
||||
Saving a new selection replaces the authorized set: channels left out of it stop being authorized, and channels added to it are authorized but not yet confirmed. Changing which channels are in the set also resets the integration's connection state, so the check runs again over the new set — reordering the same channels does not. Saving an empty selection leaves the integration with no authorized channels, and **Test connection** cannot be run again until at least one channel is authorized.
|
||||
|
||||
@@ -161,7 +159,7 @@ The Slack management page reports the state of the connection and offers these a
|
||||
| Button | Purpose | Notes |
|
||||
|--------|---------|-------|
|
||||
| **Test connection** | Verify the credential and every authorized channel, and confirm the ones not confirmed yet | Posts the confirmation message once per channel and updates the last-checked time. Cannot be run until at least one channel is authorized |
|
||||
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler Cloud` to a private channel |
|
||||
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
|
||||
| **Save channels** | Record the selected channels as the integration's authorized set | Enabled once the selection differs from the authorized set |
|
||||
| **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting |
|
||||
|
||||
@@ -173,7 +171,7 @@ The Prowler Slack app is not configured for the deployment being used, so no wor
|
||||
|
||||
### A Private Channel Does Not Appear in the Channel List
|
||||
|
||||
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler Cloud` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
|
||||
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
|
||||
|
||||
### Connection Test Fails
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 'Import Findings'
|
||||
sidebarTitle: 'Import Findings'
|
||||
description: 'Upload OCSF scan results to Prowler Cloud from the UI, the CLI or the API'
|
||||
description: 'Upload OCSF scan results to Prowler Cloud from external sources or the CLI'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
@@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.19.0" />
|
||||
|
||||
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. Reports can be imported from the Scans page in the Prowler Cloud UI, pushed by the CLI with `--push-to-cloud`, or submitted through the API.
|
||||
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class.
|
||||
|
||||
<SubscriptionBanner />
|
||||
|
||||
@@ -132,32 +132,10 @@ Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF
|
||||
|
||||
## Required Permissions
|
||||
|
||||
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted from the Scans page, via the API or with `--push-to-cloud`.
|
||||
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`.
|
||||
|
||||
For more information about RBAC permissions, refer to the [Prowler Cloud RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
|
||||
|
||||
## Using the UI
|
||||
|
||||
<VersionBadge version="5.41.0" />
|
||||
|
||||
The Scans page imports a Prowler OCSF report from the browser, with no CLI or API key involved. The import runs as a regular ingestion job, so the [status values](#ingestion-status-values), the [billing impact](#billing-impact) and the [errors endpoint](#get-ingestion-errors) apply as they do for the CLI and the API.
|
||||
|
||||
1. Go to **Scans** and click **Import Findings**. The button is shown only to roles with the **Manage Ingestions** permission.
|
||||
|
||||

|
||||
|
||||
2. Drag a `.ocsf.json` report onto the drop area, or click **Select File** to pick one. The dialog takes one file per import. A file whose name does not end in `.ocsf.json`, or an empty file, is rejected before the upload starts.
|
||||
|
||||

|
||||
|
||||
3. Click **Start import**. The dialog uploads the report, creates the ingestion job and follows its status until the job finishes. On completion it reports the total number of records, how many were processed and how many were invalid.
|
||||
|
||||
Closing the dialog while an import is running does not cancel the job. When the job completes in the background, a notification confirms it and the imported findings appear in Scans.
|
||||
|
||||
If the upload is rejected or the job fails, the dialog shows the reason and a **Retry import** button that sends the same file again. A failed job also shows the progress it reported before failing. A different file can be selected instead of retrying. If the status check fails after the upload was accepted, **Retry status** resumes tracking the same job without uploading the file again.
|
||||
|
||||
Invalid records are counted in the summary but not listed in the dialog. To see why each one was rejected, [list the ingestion jobs](#list-ingestion-jobs) through the API and query the [errors endpoint](#get-ingestion-errors) for that job.
|
||||
|
||||
## Using the CLI
|
||||
|
||||
The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after a scan completes. This approach automates the ingestion process without manual file uploads.
|
||||
@@ -466,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,31 +4,6 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [0.12.1] (Prowler v5.42.0)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `libuuid` upgraded to 2.41.6-r1 in the container image, patching CVE-2026-53612, CVE-2026-53613, CVE-2026-53614, CVE-2026-76642, CVE-2026-78408 and CVE-2026-78410 [(#12780)](https://github.com/prowler-cloud/prowler/pull/12780)
|
||||
|
||||
---
|
||||
|
||||
## [0.12.0] (Prowler v5.41.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- 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 [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- `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 [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `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 [(#12533)](https://github.com/prowler-cloud/prowler/pull/12533)
|
||||
- `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 [(#12534)](https://github.com/prowler-cloud/prowler/pull/12534)
|
||||
|
||||
---
|
||||
|
||||
## [0.11.0] (Prowler v5.40.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -32,8 +32,6 @@ LABEL maintainer="https://github.com/prowler-cloud"
|
||||
# High CVEs fixed in Alpine 3.23 but not yet in the pinned base image:
|
||||
# sqlite-libs 3.53.4-r0 CVE-2026-11822, CVE-2026-11824 (image ships 3.51.2-r0)
|
||||
# libcrypto3/libssl3 3.5.8-r0 CVE-2026-14456 (image ships 3.5.7-r0)
|
||||
# libuuid 2.41.6-r1 CVE-2026-53612, -53613, -53614, -76642, -78408, -78410
|
||||
# (image ships 2.41.4-r0; -78408 is the one that needs -r1 rather than -r0)
|
||||
# The base image pins python 3.13.14, which has not been rebuilt since those
|
||||
# packages were published, so the upgrade is taken here rather than by moving
|
||||
# the pin -- the newest published python:3.13-alpine3.23 carries the same
|
||||
@@ -45,8 +43,7 @@ LABEL maintainer="https://github.com/prowler-cloud"
|
||||
RUN apk add --no-cache --upgrade \
|
||||
"sqlite-libs>=3.53.4-r0" \
|
||||
"libcrypto3>=3.5.8-r0" \
|
||||
"libssl3>=3.5.8-r0" \
|
||||
"libuuid>=2.41.6-r1"
|
||||
"libssl3>=3.5.8-r0"
|
||||
|
||||
# Create non-root user for security
|
||||
# Using specific UID/GID for consistency across environments
|
||||
|
||||
@@ -19,17 +19,10 @@ 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,
|
||||
payload: dict[str, Any] | None = None,
|
||||
self, message: str, status_code: int, *, detail: str | None = None
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code: int = status_code
|
||||
@@ -39,12 +32,6 @@ 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):
|
||||
@@ -55,59 +42,6 @@ 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.
|
||||
|
||||
@@ -137,10 +71,6 @@ 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
|
||||
|
||||
|
||||
@@ -224,27 +154,6 @@ 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 (
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""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)]
|
||||
@@ -1,36 +0,0 @@
|
||||
"""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,14 +354,6 @@ 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(
|
||||
@@ -376,15 +368,7 @@ class AttackPathQueryResult(MinimalSerializerMixin, BaseModel):
|
||||
Returns:
|
||||
AttackPathQueryResult with parsed data and summary
|
||||
"""
|
||||
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 = {}
|
||||
|
||||
attributes = response.get("data", {}).get("attributes")
|
||||
nodes_data = attributes.get("nodes", [])
|
||||
relationships_data = attributes.get("relationships", [])
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
|
||||
|
||||
@@ -104,29 +104,6 @@ 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 a scan creation that succeeded.
|
||||
"""Result of scan creation operation.
|
||||
|
||||
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.
|
||||
Used by trigger_scan() to communicate the outcome of scan creation.
|
||||
Status indicates whether scan was created successfully or failed.
|
||||
"""
|
||||
|
||||
scan: DetailedScan = Field(
|
||||
description="Detailed information about the scan that was created"
|
||||
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)"
|
||||
)
|
||||
message: str = Field(
|
||||
description="Human-readable message describing the scan creation result"
|
||||
@@ -210,26 +210,13 @@ class ScanCreationResult(MinimalSerializerMixin, BaseModel):
|
||||
|
||||
|
||||
class ScheduleCreationResult(MinimalSerializerMixin, BaseModel):
|
||||
"""Result of a daily schedule creation that succeeded.
|
||||
"""Result of async schedule creation operation.
|
||||
|
||||
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.
|
||||
Used by schedule_daily_scan() to communicate scheduling outcome.
|
||||
"""
|
||||
|
||||
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"
|
||||
),
|
||||
scheduled: bool = Field(
|
||||
description="Whether the daily scan schedule was created successfully"
|
||||
)
|
||||
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", mask_error_details=True)
|
||||
app_mcp_server = FastMCP("prowler-app")
|
||||
|
||||
# Auto-discover and load all tools from the tools package
|
||||
load_all_tools(app_mcp_server)
|
||||
|
||||
@@ -7,11 +7,8 @@ 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,
|
||||
@@ -79,47 +76,50 @@ 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
|
||||
"""
|
||||
# Validate pagination
|
||||
self.api_client.validate_page_size(page_size)
|
||||
try:
|
||||
# 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()
|
||||
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)}"}
|
||||
|
||||
async def list_attack_paths_queries(
|
||||
self,
|
||||
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"
|
||||
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"
|
||||
),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Discover available Attack Paths queries for a completed scan.
|
||||
|
||||
IMPORTANT: The scan must be in 'completed' state to list queries.
|
||||
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.
|
||||
Queries are provider-specific
|
||||
|
||||
Each query includes:
|
||||
- id: Query identifier to use with run_attack_paths_query
|
||||
@@ -141,32 +141,23 @@ 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", [])
|
||||
]
|
||||
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)}"}]
|
||||
|
||||
async def run_attack_paths_query(
|
||||
self,
|
||||
scan_id: NonBlankStr = Field(
|
||||
scan_id: str = Field(
|
||||
description="UUID of a COMPLETED attack paths scan. The scan must be in 'completed' state"
|
||||
),
|
||||
query_id: NonBlankStr = Field(
|
||||
query_id: str = 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(
|
||||
@@ -207,61 +198,39 @@ class AttackPathsTools(BaseTool):
|
||||
3. Execute this tool with appropriate parameters
|
||||
4. Analyze the returned graph for security insights
|
||||
"""
|
||||
# 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
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Add parameters if provided
|
||||
if parameters:
|
||||
request_data["data"]["attributes"]["parameters"] = parameters
|
||||
|
||||
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)
|
||||
|
||||
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 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}"
|
||||
)
|
||||
|
||||
return query_result.model_dump()
|
||||
return {"error": f"Failed to run attack paths query '{query_id}': {str(e)}"}
|
||||
|
||||
async def get_attack_paths_cartography_schema(
|
||||
self,
|
||||
scan_id: NonBlankStr = Field(
|
||||
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"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -293,43 +262,18 @@ 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()
|
||||
|
||||
# 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."
|
||||
)
|
||||
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)}"}
|
||||
|
||||
@@ -6,11 +6,8 @@ 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,
|
||||
@@ -37,7 +34,7 @@ class ComplianceTools(BaseTool):
|
||||
The scan_id of the latest completed scan for the provider.
|
||||
|
||||
Raises:
|
||||
ToolError: If no completed scans are found for the provider
|
||||
ValueError: If no completed scans are found for the provider.
|
||||
"""
|
||||
scan_params = {
|
||||
"filter[provider]": provider_id,
|
||||
@@ -51,7 +48,7 @@ class ComplianceTools(BaseTool):
|
||||
|
||||
scans_data = scans_response.get("data", [])
|
||||
if not scans_data:
|
||||
raise ToolError(
|
||||
raise ValueError(
|
||||
f"No completed scans found for provider {provider_id}. "
|
||||
"Run a scan first using prowler_trigger_scan."
|
||||
)
|
||||
@@ -96,15 +93,18 @@ 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:
|
||||
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."
|
||||
)
|
||||
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."
|
||||
}
|
||||
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."
|
||||
)
|
||||
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."
|
||||
}
|
||||
elif not scan_id and provider_id:
|
||||
scan_id = await self._get_latest_scan_id_for_provider(provider_id)
|
||||
try:
|
||||
scan_id = await self._get_latest_scan_id_for_provider(provider_id)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
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: NonBlankStr = Field(
|
||||
compliance_id: str = 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. Do not pass it together with provider_id.",
|
||||
description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified.",
|
||||
),
|
||||
provider_id: str | None = Field(
|
||||
default=None,
|
||||
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.",
|
||||
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.",
|
||||
),
|
||||
) -> 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 exactly one of scan_id OR provider_id; providing both is rejected
|
||||
- With provider_id: Automatically finds the latest completed scan for that provider
|
||||
- Requires either scan_id OR provider_id
|
||||
- With provider_id (no scan_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,22 +293,21 @@ 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
|
||||
"""
|
||||
# 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.
|
||||
# Validate that either scan_id or provider_id is provided
|
||||
if not scan_id and not provider_id:
|
||||
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."
|
||||
)
|
||||
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."
|
||||
}
|
||||
|
||||
# Resolve provider_id to latest scan_id if needed
|
||||
resolved_scan_id = scan_id
|
||||
if not scan_id and provider_id:
|
||||
resolved_scan_id = await self._get_latest_scan_id_for_provider(provider_id)
|
||||
try:
|
||||
resolved_scan_id = await self._get_latest_scan_id_for_provider(
|
||||
provider_id
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# Build params for requirements endpoint
|
||||
params: dict[str, Any] = {
|
||||
|
||||
@@ -6,10 +6,8 @@ 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,
|
||||
@@ -238,46 +236,50 @@ class FindingGroupsTools(BaseTool):
|
||||
prowler_get_finding_group_details for complete counters or
|
||||
prowler_list_finding_group_resources to drill into affected resources.
|
||||
"""
|
||||
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)
|
||||
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._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()
|
||||
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"}
|
||||
|
||||
async def get_finding_group_details(
|
||||
self,
|
||||
check_id: NonBlankStr = Field(
|
||||
check_id: str = Field(
|
||||
description="Public check ID that identifies the finding group. This is not a UUID."
|
||||
),
|
||||
date_from: str | None = Field(
|
||||
@@ -295,37 +297,39 @@ 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.
|
||||
"""
|
||||
date_range, params = self._base_date_params(date_from, date_to)
|
||||
endpoint = self._group_endpoint(date_range)
|
||||
try:
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
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."
|
||||
params.update(
|
||||
{
|
||||
"filter[check_id]": check_id,
|
||||
"filter[include_muted]": True,
|
||||
"page[size]": 1,
|
||||
"page[number]": 1,
|
||||
"fields[finding-groups]": GROUP_DETAIL_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
group = DetailedFindingGroup.from_api_response(data[0])
|
||||
return group.model_dump()
|
||||
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"}
|
||||
|
||||
async def list_finding_group_resources(
|
||||
self,
|
||||
check_id: NonBlankStr = Field(
|
||||
check_id: str = Field(
|
||||
description="Public check ID that identifies the finding group. This is not a UUID."
|
||||
),
|
||||
provider: list[str] = Field(
|
||||
@@ -422,41 +426,45 @@ class FindingGroupsTools(BaseTool):
|
||||
`finding_id`. Use `prowler_get_finding_details(finding_id)` to
|
||||
retrieve complete remediation guidance for a specific resource finding.
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
|
||||
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()
|
||||
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"}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
@@ -181,7 +180,7 @@ class FindingsTools(BaseTool):
|
||||
|
||||
async def get_finding_details(
|
||||
self,
|
||||
finding_id: NonBlankStr = Field(
|
||||
finding_id: str = 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,11 +9,8 @@ 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,
|
||||
@@ -129,7 +126,7 @@ class IntegrationsTools(BaseTool):
|
||||
|
||||
async def get_integration(
|
||||
self,
|
||||
integration_id: NonBlankStr = Field(
|
||||
integration_id: str = 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]:
|
||||
@@ -160,7 +157,7 @@ class IntegrationsTools(BaseTool):
|
||||
|
||||
async def create_amazon_s3_integration(
|
||||
self,
|
||||
bucket_name: NonBlankStr = Field(
|
||||
bucket_name: str = 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(
|
||||
@@ -171,15 +168,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: NonBlankStr | None = Field(
|
||||
role_arn: str | 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: NonBlankStr | None = Field(
|
||||
external_id: str | 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: NonBlankStr | None = Field(
|
||||
role_session_name: str | 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.",
|
||||
),
|
||||
@@ -187,15 +184,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: NonBlankStr | None = Field(
|
||||
aws_access_key_id: str | None = Field(
|
||||
default=None,
|
||||
description="AWS access key ID. Only needed when the Prowler deployment has no ambient AWS credentials.",
|
||||
),
|
||||
aws_secret_access_key: NonBlankStr | None = Field(
|
||||
aws_secret_access_key: str | None = Field(
|
||||
default=None,
|
||||
description="AWS secret access key. Required when 'aws_access_key_id' is provided.",
|
||||
),
|
||||
aws_session_token: NonBlankStr | None = Field(
|
||||
aws_session_token: str | None = Field(
|
||||
default=None,
|
||||
description="AWS session token, only for temporary credentials.",
|
||||
),
|
||||
@@ -247,30 +244,34 @@ class IntegrationsTools(BaseTool):
|
||||
"""
|
||||
self.logger.info(f"Creating Amazon S3 integration for bucket {bucket_name}...")
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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"}
|
||||
|
||||
async def create_aws_security_hub_integration(
|
||||
self,
|
||||
provider_id: NonBlankStr = Field(
|
||||
provider_id: str = 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(
|
||||
@@ -281,15 +282,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: NonBlankStr | None = Field(
|
||||
role_arn: str | 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: NonBlankStr | None = Field(
|
||||
external_id: str | None = Field(
|
||||
default=None,
|
||||
description="External ID required by the trust policy of the assumed role.",
|
||||
),
|
||||
role_session_name: NonBlankStr | None = Field(
|
||||
role_session_name: str | 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.",
|
||||
),
|
||||
@@ -297,14 +298,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: NonBlankStr | None = Field(
|
||||
aws_access_key_id: str | None = Field(
|
||||
default=None, description="AWS access key ID for dedicated credentials."
|
||||
),
|
||||
aws_secret_access_key: NonBlankStr | None = Field(
|
||||
aws_secret_access_key: str | None = Field(
|
||||
default=None,
|
||||
description="AWS secret access key. Required when 'aws_access_key_id' is provided.",
|
||||
),
|
||||
aws_session_token: NonBlankStr | None = Field(
|
||||
aws_session_token: str | None = Field(
|
||||
default=None,
|
||||
description="AWS session token, only for temporary credentials.",
|
||||
),
|
||||
@@ -343,36 +344,40 @@ class IntegrationsTools(BaseTool):
|
||||
f"Creating AWS Security Hub integration for provider {provider_id}..."
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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"}
|
||||
|
||||
async def create_jira_integration(
|
||||
self,
|
||||
domain: NonBlankStr = Field(
|
||||
domain: str = 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: NonBlankStr = Field(
|
||||
user_mail: str = Field(
|
||||
description="Email address of the Atlassian account that owns the API token."
|
||||
),
|
||||
api_token: NonBlankStr = Field(
|
||||
api_token: str = 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(
|
||||
@@ -411,25 +416,31 @@ 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
|
||||
"""
|
||||
normalized_domain = self._normalize_atlassian_domain(domain)
|
||||
self.logger.info(f"Creating Jira integration for domain {normalized_domain}...")
|
||||
try:
|
||||
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,
|
||||
)
|
||||
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"}
|
||||
|
||||
async def update_integration(
|
||||
self,
|
||||
integration_id: NonBlankStr = Field(
|
||||
integration_id: str = Field(
|
||||
description="UUID of the integration to update. Use prowler_list_integrations to find it."
|
||||
),
|
||||
enabled: bool | None = Field(
|
||||
@@ -483,86 +494,96 @@ class IntegrationsTools(BaseTool):
|
||||
"""
|
||||
self.logger.info(f"Updating integration {integration_id}...")
|
||||
|
||||
current = DetailedIntegration.from_api_response(
|
||||
await self._get_integration_raw(integration_id)
|
||||
)
|
||||
integration_type = current.integration_type
|
||||
try:
|
||||
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 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 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")
|
||||
)
|
||||
|
||||
attributes: dict[str, Any] = {}
|
||||
if enabled is not None:
|
||||
attributes["enabled"] = enabled
|
||||
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
|
||||
|
||||
if credentials is not None:
|
||||
attributes["credentials"] = self._validate_credentials(
|
||||
integration_type, self._as_dict(credentials, "credentials")
|
||||
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
|
||||
)
|
||||
|
||||
await self.api_client.patch(
|
||||
f"/integrations/{integration_id}", json_data=update_body
|
||||
)
|
||||
|
||||
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
|
||||
# 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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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()
|
||||
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"}
|
||||
|
||||
async def delete_integration(
|
||||
self,
|
||||
integration_id: NonBlankStr = Field(
|
||||
integration_id: str = Field(
|
||||
description="UUID of the integration to permanently remove. Use prowler_list_integrations to find it."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -585,15 +606,22 @@ class IntegrationsTools(BaseTool):
|
||||
"""
|
||||
self.logger.info(f"Deleting integration {integration_id}...")
|
||||
|
||||
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"}
|
||||
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)}",
|
||||
}
|
||||
|
||||
async def test_integration_connection(
|
||||
self,
|
||||
integration_id: NonBlankStr = Field(
|
||||
integration_id: str = Field(
|
||||
description="UUID of the integration to check. Use prowler_list_integrations to find it."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -626,10 +654,10 @@ class IntegrationsTools(BaseTool):
|
||||
|
||||
async def get_jira_issue_types(
|
||||
self,
|
||||
integration_id: NonBlankStr = Field(
|
||||
integration_id: str = Field(
|
||||
description="UUID of the Jira integration. Use prowler_list_integrations with integration_type=['jira'] to find it."
|
||||
),
|
||||
project_key: NonBlankStr = Field(
|
||||
project_key: str = 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]:
|
||||
@@ -664,13 +692,13 @@ class IntegrationsTools(BaseTool):
|
||||
|
||||
async def send_findings_to_jira(
|
||||
self,
|
||||
integration_id: NonBlankStr = Field(
|
||||
integration_id: str = Field(
|
||||
description="UUID of the Jira integration to send the findings through. It must be enabled."
|
||||
),
|
||||
project_key: NonBlankStr = Field(
|
||||
project_key: str = 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: NonBlankStr = Field(
|
||||
issue_type: str = 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(
|
||||
@@ -755,26 +783,20 @@ class IntegrationsTools(BaseTool):
|
||||
return self._jira_dispatch_unknown(
|
||||
task_id=None,
|
||||
error=(
|
||||
"the request that starts the dispatch failed on Prowler's side. "
|
||||
f"the request that starts the dispatch failed on the server: {e} "
|
||||
"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=(
|
||||
"the request that starts the dispatch got no answer. "
|
||||
f"the request that starts the dispatch got no answer: {e} "
|
||||
"It may have been accepted anyway."
|
||||
),
|
||||
)
|
||||
@@ -844,7 +866,7 @@ class IntegrationsTools(BaseTool):
|
||||
normalized = normalized.removesuffix(".atlassian.net")
|
||||
|
||||
if not normalized:
|
||||
raise InvalidArgument(
|
||||
raise ValueError(
|
||||
f"Invalid Jira domain: {domain}. Provide the Atlassian site name, for example "
|
||||
"'acme' for the site 'https://acme.atlassian.net'."
|
||||
)
|
||||
@@ -868,7 +890,7 @@ class IntegrationsTools(BaseTool):
|
||||
if not isinstance(credentials.get(key), str) or not credentials[key].strip()
|
||||
]
|
||||
if missing:
|
||||
raise InvalidArgument(
|
||||
raise ValueError(
|
||||
"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 "
|
||||
@@ -886,33 +908,29 @@ class IntegrationsTools(BaseTool):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
raise InvalidArgument(f"Invalid JSON for {param_name}: {e}") from e
|
||||
raise ValueError(f"Invalid JSON for {param_name}: {e}")
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise InvalidArgument(f"{param_name} must be a JSON object.")
|
||||
raise ValueError(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:
|
||||
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.
|
||||
ValueError: If the payload does not contain a usable integration resource
|
||||
"""
|
||||
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 ToolError(
|
||||
raise ValueError(
|
||||
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 ToolError(
|
||||
raise ValueError(
|
||||
f"Prowler returned integration {integration_id} without its attributes, so "
|
||||
"its state cannot be read."
|
||||
)
|
||||
@@ -952,9 +970,7 @@ class IntegrationsTools(BaseTool):
|
||||
integration_id = api_response.get("data", {}).get("id")
|
||||
|
||||
if not integration_id:
|
||||
# The integration may well exist, so this must not read as "nothing
|
||||
# happened" and invite a duplicate.
|
||||
raise ToolError(
|
||||
raise ValueError(
|
||||
"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."
|
||||
@@ -965,17 +981,11 @@ 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. 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. "
|
||||
# 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} "
|
||||
"Use prowler_get_integration with that ID to check it."
|
||||
)
|
||||
) from e
|
||||
|
||||
return IntegrationConnectionStatus.create(
|
||||
integration, connection_status
|
||||
@@ -1020,7 +1030,7 @@ class IntegrationsTools(BaseTool):
|
||||
return {
|
||||
"connected": None,
|
||||
"error": (
|
||||
"The connection check could not be completed. This says nothing "
|
||||
f"The connection check could not be completed: {e} This says nothing "
|
||||
"about the stored credentials, run prowler_test_integration_connection "
|
||||
"to check them again."
|
||||
),
|
||||
|
||||
@@ -8,11 +8,8 @@ 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,
|
||||
@@ -31,31 +28,10 @@ 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. Fails with a message saying so if no mutelist exists.
|
||||
IMPORTANT: Only one mutelist can exist per tenant. Returns an error message 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.
|
||||
|
||||
@@ -71,15 +47,26 @@ class MutingTools(BaseTool):
|
||||
"""
|
||||
self.logger.info("Retrieving mutelist configuration...")
|
||||
|
||||
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
|
||||
# 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()
|
||||
|
||||
async def set_mutelist(
|
||||
self,
|
||||
@@ -141,9 +128,9 @@ Structure:
|
||||
configuration = json.loads(configuration)
|
||||
|
||||
# Check if mutelist already exists
|
||||
existing_mutelist = await self._get_mutelist_raw()
|
||||
existing_mutelist = await self.get_mutelist()
|
||||
|
||||
if existing_mutelist is None:
|
||||
if "error" in existing_mutelist:
|
||||
# Create new mutelist
|
||||
self.logger.info("Creating new mutelist...")
|
||||
create_body = {
|
||||
@@ -196,22 +183,21 @@ Structure:
|
||||
self.logger.info("Deleting mutelist configuration...")
|
||||
|
||||
# Get existing mutelist
|
||||
existing_mutelist = await self._get_mutelist_raw()
|
||||
existing_mutelist = await self.get_mutelist()
|
||||
|
||||
if existing_mutelist is None:
|
||||
raise ToolError(
|
||||
"There is no mutelist configuration to delete. Use "
|
||||
"prowler_get_mutelist to confirm the current state."
|
||||
)
|
||||
if "error" in existing_mutelist:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No mutelist found to delete",
|
||||
}
|
||||
|
||||
# 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 {
|
||||
"message": "Mutelist deleted successfully. Findings it had muted stay muted."
|
||||
"success": True,
|
||||
"message": "Mutelist deleted successfully",
|
||||
}
|
||||
|
||||
# ===== MUTE RULES TOOLS =====
|
||||
@@ -282,7 +268,7 @@ Structure:
|
||||
elif enabled.lower() == "false":
|
||||
params["filter[enabled]"] = False
|
||||
else:
|
||||
raise InvalidArgument(
|
||||
raise ValueError(
|
||||
f"Invalid enabled value: {enabled}. Valid values are True, False, 'true', 'false' or None."
|
||||
)
|
||||
if search:
|
||||
@@ -296,7 +282,7 @@ Structure:
|
||||
|
||||
async def get_mute_rule(
|
||||
self,
|
||||
rule_id: NonBlankStr = Field(
|
||||
rule_id: str = 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]:
|
||||
@@ -330,10 +316,10 @@ Structure:
|
||||
|
||||
async def create_mute_rule(
|
||||
self,
|
||||
name: NonBlankStr = Field(
|
||||
name: str = Field(
|
||||
description="Name for the mute rule. Should be descriptive and meaningful (e.g., 'Dev S3 Public Access', 'Test Environment IMDSv1')."
|
||||
),
|
||||
reason: NonBlankStr = Field(
|
||||
reason: str = 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(
|
||||
@@ -381,14 +367,14 @@ Structure:
|
||||
|
||||
async def update_mute_rule(
|
||||
self,
|
||||
rule_id: NonBlankStr = Field(
|
||||
rule_id: str = Field(
|
||||
description="UUID of the mute rule to update. Must be a valid UUID format."
|
||||
),
|
||||
name: NonBlankStr | None = Field(
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
description="New name for the rule. If not specified, name remains unchanged.",
|
||||
),
|
||||
reason: NonBlankStr | None = Field(
|
||||
reason: str | None = Field(
|
||||
default=None,
|
||||
description="New reason for the rule. If not specified, reason remains unchanged.",
|
||||
),
|
||||
@@ -449,7 +435,7 @@ Structure:
|
||||
|
||||
async def delete_mute_rule(
|
||||
self,
|
||||
rule_id: NonBlankStr = Field(
|
||||
rule_id: str = Field(
|
||||
description="UUID of the mute rule to delete. Must be a valid UUID format."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
@@ -471,18 +457,15 @@ Structure:
|
||||
"""
|
||||
self.logger.info(f"Deleting mute rule {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}")
|
||||
result = await self.api_client.delete(f"/mute-rules/{rule_id}")
|
||||
|
||||
return {
|
||||
"message": (
|
||||
f"Mute rule {rule_id} deleted successfully. The findings it muted stay "
|
||||
"muted."
|
||||
)
|
||||
}
|
||||
if result.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Mute rule deleted successfully",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to delete mute rule",
|
||||
}
|
||||
|
||||
@@ -6,14 +6,10 @@ 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
|
||||
@@ -99,7 +95,7 @@ class ProvidersTools(BaseTool):
|
||||
elif connected.lower() == "false":
|
||||
params["filter[connected]"] = False
|
||||
else:
|
||||
raise InvalidArgument(
|
||||
raise ValueError(
|
||||
f"Invalid connected value: {connected}. Valid values are True, False, 'true', 'false' or None."
|
||||
)
|
||||
|
||||
@@ -132,13 +128,13 @@ class ProvidersTools(BaseTool):
|
||||
|
||||
async def connect_provider(
|
||||
self,
|
||||
provider_uid: NonBlankStr = Field(
|
||||
provider_uid: str = 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: NonBlankStr = Field(
|
||||
provider_type: str = 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: NonBlankStr | None = Field(
|
||||
alias: str | 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.",
|
||||
),
|
||||
@@ -295,7 +291,7 @@ class ProvidersTools(BaseTool):
|
||||
|
||||
async def delete_provider(
|
||||
self,
|
||||
provider_id: NonBlankStr = Field(
|
||||
provider_id: str = 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]:
|
||||
@@ -304,120 +300,33 @@ 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.
|
||||
|
||||
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.
|
||||
The tool always returns the deletion status and message.
|
||||
"""
|
||||
self.logger.info(f"Deleting provider {provider_id}...")
|
||||
|
||||
# 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:
|
||||
# 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)
|
||||
await self.api_client.poll_task_until_complete(
|
||||
task_id=task_id, timeout=60, poll_interval=1.0
|
||||
)
|
||||
except Exception as 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()
|
||||
# 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)}",
|
||||
}
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -448,7 +357,7 @@ class ProvidersTools(BaseTool):
|
||||
return prowler_provider_id
|
||||
else:
|
||||
# Multiple providers with the same UID is a data integrity issue
|
||||
raise ToolError(
|
||||
raise Exception(
|
||||
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."
|
||||
)
|
||||
@@ -483,11 +392,7 @@ class ProvidersTools(BaseTool):
|
||||
|
||||
provider_id = await self._check_provider_exists(provider_uid)
|
||||
if provider_id is None:
|
||||
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."
|
||||
)
|
||||
raise Exception(f"Provider {provider_uid} creation failed")
|
||||
return provider_id
|
||||
|
||||
async def _update_provider_alias(
|
||||
@@ -513,10 +418,7 @@ class ProvidersTools(BaseTool):
|
||||
f"/providers/{prowler_provider_id}", json_data=update_body
|
||||
)
|
||||
if result.get("data", {}).get("attributes", {}).get("alias") != alias:
|
||||
raise ToolError(
|
||||
f"Provider {prowler_provider_id} exists, but its alias was not updated. "
|
||||
"Use prowler_search_providers to read its current alias."
|
||||
)
|
||||
raise Exception(f"Provider {prowler_provider_id} alias update failed")
|
||||
|
||||
def _determine_secret_type(self, credentials: dict[str, Any]) -> str:
|
||||
"""Determine the secret type from credentials structure.
|
||||
@@ -541,32 +443,29 @@ class ProvidersTools(BaseTool):
|
||||
prowler_provider_id: The Prowler-generated provider ID
|
||||
|
||||
Returns:
|
||||
The secret ID if the provider has one, None if it has none
|
||||
|
||||
Raises:
|
||||
Exception: If the lookup itself failed, so that "no secret" is never
|
||||
reported for a provider whose secret could not be read
|
||||
The secret ID if exists, None otherwise
|
||||
"""
|
||||
# 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}"
|
||||
try:
|
||||
response = await self.api_client.get(
|
||||
"/providers/secrets",
|
||||
params={"filter[provider]": prowler_provider_id},
|
||||
)
|
||||
return secret_id
|
||||
secrets = response.get("data", [])
|
||||
|
||||
self.logger.info(f"No existing secret found for provider {prowler_provider_id}")
|
||||
return 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
|
||||
|
||||
async def _get_secret_type(self, secret_id: str) -> str | None:
|
||||
"""Get the secret type for a given secret ID.
|
||||
@@ -674,24 +573,13 @@ class ProvidersTools(BaseTool):
|
||||
raise
|
||||
|
||||
async def _test_connection(self, prowler_provider_id: str) -> dict[str, Any]:
|
||||
"""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.
|
||||
"""Test connection to a provider.
|
||||
|
||||
Args:
|
||||
prowler_provider_id: The Prowler-generated provider ID
|
||||
|
||||
Returns:
|
||||
Connection status dictionary with a 'connected' boolean or None, and
|
||||
an optional 'error' message
|
||||
Connection status dictionary with 'connected' boolean and optional 'error' message
|
||||
"""
|
||||
self.logger.info(f"Testing connection for provider {prowler_provider_id}...")
|
||||
try:
|
||||
@@ -701,11 +589,6 @@ 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
|
||||
@@ -713,26 +596,13 @@ 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 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."
|
||||
),
|
||||
}
|
||||
self.logger.error(f"Connection test failed: {e}")
|
||||
return {"connected": False, "error": str(e)}
|
||||
|
||||
async def _get_final_provider_state(
|
||||
self, prowler_provider_id: str
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
@@ -177,7 +176,7 @@ class ResourcesTools(BaseTool):
|
||||
|
||||
async def get_resource(
|
||||
self,
|
||||
resource_id: NonBlankStr = Field(
|
||||
resource_id: str = 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]:
|
||||
@@ -348,7 +347,7 @@ class ResourcesTools(BaseTool):
|
||||
|
||||
async def get_resource_events(
|
||||
self,
|
||||
resource_id: NonBlankStr = Field(
|
||||
resource_id: str = 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,11 +11,8 @@ 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,
|
||||
@@ -73,7 +70,7 @@ class RolesTools(BaseTool):
|
||||
|
||||
async def get_role(
|
||||
self,
|
||||
role_id: NonBlankStr = Field(
|
||||
role_id: str = 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]:
|
||||
@@ -101,7 +98,7 @@ class RolesTools(BaseTool):
|
||||
|
||||
async def get_user_roles(
|
||||
self,
|
||||
user_id: NonBlankStr = Field(
|
||||
user_id: str = 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]:
|
||||
@@ -127,10 +124,10 @@ class RolesTools(BaseTool):
|
||||
|
||||
async def set_user_role(
|
||||
self,
|
||||
user_id: NonBlankStr = Field(
|
||||
user_id: str = 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: NonBlankStr = Field(
|
||||
role_id: str = 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]:
|
||||
@@ -169,20 +166,11 @@ 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 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."
|
||||
)
|
||||
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
|
||||
|
||||
# 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,10 +5,8 @@ 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,
|
||||
@@ -129,7 +127,7 @@ class ScansTools(BaseTool):
|
||||
|
||||
async def get_scan(
|
||||
self,
|
||||
scan_id: NonBlankStr = Field(
|
||||
scan_id: str = 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]:
|
||||
@@ -173,10 +171,10 @@ class ScansTools(BaseTool):
|
||||
|
||||
async def trigger_scan(
|
||||
self,
|
||||
provider_id: NonBlankStr = Field(
|
||||
provider_id: str = 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: NonBlankStr | None = Field(
|
||||
name: str | 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'",
|
||||
),
|
||||
@@ -193,70 +191,60 @@ 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
|
||||
"""
|
||||
# Build request data
|
||||
request_data: dict[str, Any] = {
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"attributes": {},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": provider_id,
|
||||
try:
|
||||
# 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)
|
||||
)
|
||||
|
||||
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."
|
||||
scan_id = (
|
||||
task_response.get("data", {})
|
||||
.get("attributes", {})
|
||||
.get("task_args", {})
|
||||
.get("scan_id", None)
|
||||
)
|
||||
|
||||
# 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:
|
||||
if not scan_id:
|
||||
raise Exception("No scan_id returned from scan creation")
|
||||
|
||||
self.logger.info(f"Scan created successfully: {scan_id}")
|
||||
scan_response = await self.api_client.get(f"/scans/{scan_id}")
|
||||
scan_info = DetailedScan.from_api_response(scan_response["data"])
|
||||
except Exception as e:
|
||||
# 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()
|
||||
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()
|
||||
|
||||
async def schedule_daily_scan(
|
||||
self,
|
||||
provider_id: NonBlankStr = Field(
|
||||
provider_id: str = 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]:
|
||||
@@ -292,49 +280,26 @@ class ScansTools(BaseTool):
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# 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_state = (
|
||||
task_response.get("data", {}).get("attributes", {}).get("state", None)
|
||||
)
|
||||
|
||||
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."
|
||||
)
|
||||
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."
|
||||
|
||||
return ScheduleCreationResult(
|
||||
first_run_state=first_run_state,
|
||||
message=message,
|
||||
scheduled=(task_state == "available"),
|
||||
message=return_message,
|
||||
).model_dump()
|
||||
|
||||
async def update_scan(
|
||||
self,
|
||||
scan_id: NonBlankStr = Field(
|
||||
scan_id: str = 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: NonBlankStr = Field(
|
||||
name: str = 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,7 +9,6 @@ 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,
|
||||
@@ -80,7 +79,7 @@ class UsersTools(BaseTool):
|
||||
|
||||
async def get_user(
|
||||
self,
|
||||
user_id: NonBlankStr = Field(
|
||||
user_id: str = 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,21 +118,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
|
||||
if detail:
|
||||
message = f"{message} - {detail}"
|
||||
|
||||
# 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
|
||||
raise ProwlerAPIError(message, status, detail=detail) 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,7 +6,6 @@ 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
|
||||
|
||||
|
||||
@@ -65,12 +64,7 @@ class ProwlerAppAuth:
|
||||
|
||||
# Decode and parse JSON
|
||||
decoded = base64.b64decode(base64_payload).decode("utf-8")
|
||||
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
|
||||
return json.loads(decoded)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse JWT token: {e}")
|
||||
return None
|
||||
@@ -82,16 +76,14 @@ class ProwlerAppAuth:
|
||||
authorization_header = headers.get("authorization", None)
|
||||
|
||||
if not authorization_header:
|
||||
raise CredentialError("No Authorization header was sent")
|
||||
raise ValueError("No authorization header provided")
|
||||
|
||||
# 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"
|
||||
# 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>'"
|
||||
)
|
||||
|
||||
# Check if it's an API key or JWT token
|
||||
@@ -102,29 +94,17 @@ class ProwlerAppAuth:
|
||||
# JWT token - validate and check expiration
|
||||
payload = self._parse_jwt(token)
|
||||
if not payload:
|
||||
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"
|
||||
)
|
||||
raise ValueError("Invalid JWT token format")
|
||||
|
||||
# Check if token is expired
|
||||
now = int(datetime.now().timestamp())
|
||||
exp = payload.get("exp", 0)
|
||||
if exp <= now:
|
||||
raise CredentialError("The token has expired")
|
||||
raise ValueError("Token has expired")
|
||||
|
||||
return token
|
||||
else:
|
||||
# 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}")
|
||||
raise ValueError(f"Invalid mode: {self.mode}")
|
||||
|
||||
async def get_valid_token(self) -> str:
|
||||
"""Get a valid token (API key or JWT token)."""
|
||||
|
||||
@@ -2,7 +2,6 @@ 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):
|
||||
@@ -59,7 +58,8 @@ 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,85 +69,82 @@ 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
|
||||
"""
|
||||
# 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", "")
|
||||
|
||||
# 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}"
|
||||
|
||||
results.append(
|
||||
SearchResult(
|
||||
path=doc_path,
|
||||
title=title,
|
||||
section=section,
|
||||
breadcrumbs=breadcrumbs,
|
||||
url=url,
|
||||
excerpt=match.get("content", ""),
|
||||
score=match.get("score", 0.0),
|
||||
)
|
||||
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()
|
||||
|
||||
return results
|
||||
# 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
|
||||
|
||||
# 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),
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
# Return empty list on error
|
||||
print(f"Search error: {e}")
|
||||
return []
|
||||
|
||||
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 there is no
|
||||
page at that path
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If the fetch failed for any reason other than a 404
|
||||
Full markdown content of the documentation, or None if not found
|
||||
"""
|
||||
# Clean up the path
|
||||
doc_path = doc_path.rstrip("/")
|
||||
try:
|
||||
# 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)
|
||||
if response.status_code == 404:
|
||||
# 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}")
|
||||
return None
|
||||
response.raise_for_status()
|
||||
|
||||
return response.text
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
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", mask_error_details=True)
|
||||
docs_mcp_server = FastMCP("prowler-docs")
|
||||
prowler_docs_search_engine = ProwlerDocsSearchEngine()
|
||||
|
||||
|
||||
@docs_mcp_server.tool()
|
||||
def search(
|
||||
term: NonBlankStr = Field(
|
||||
description="The term to search for in the documentation"
|
||||
),
|
||||
term: str = 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.",
|
||||
@@ -43,7 +39,7 @@ def search(
|
||||
|
||||
@docs_mcp_server.tool()
|
||||
def get_document(
|
||||
doc_path: NonBlankStr = Field(
|
||||
doc_path: str = 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]:
|
||||
@@ -57,10 +53,6 @@ def get_document(
|
||||
"""
|
||||
content: str | None = prowler_docs_search_engine.get_document(doc_path)
|
||||
if content is None:
|
||||
# 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}
|
||||
return {"error": f"Document '{doc_path}' not found."}
|
||||
else:
|
||||
return {"content": content}
|
||||
|
||||
@@ -6,19 +6,12 @@ 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", mask_error_details=True)
|
||||
hub_mcp_server = FastMCP("prowler-hub")
|
||||
|
||||
# API base URL
|
||||
BASE_URL = "https://hub.prowler.com/api"
|
||||
@@ -33,19 +26,6 @@ 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/"
|
||||
@@ -62,21 +42,6 @@ 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.
|
||||
@@ -87,83 +52,7 @@ 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
|
||||
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
|
||||
return f"{GITHUB_RAW_BASE}/{provider_id}/services/{service_id}/{check_id}/{check_id}{suffix}"
|
||||
|
||||
|
||||
# Security Check Tools
|
||||
@@ -233,27 +122,34 @@ async def list_checks(
|
||||
if compliances:
|
||||
params["compliances"] = ",".join(compliances)
|
||||
|
||||
response = _get_hub_endpoint("check", params=params)
|
||||
response.raise_for_status()
|
||||
checks = parse_json_response(response)
|
||||
try:
|
||||
response = prowler_hub_client.get("/check", params=params)
|
||||
response.raise_for_status()
|
||||
checks = response.json()
|
||||
|
||||
# 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"],
|
||||
# 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}",
|
||||
}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def semantic_search_checks(
|
||||
term: NonBlankStr = Field(
|
||||
term: str = Field(
|
||||
description="Search term. Examples: 'public access', 'encryption', 'MFA', 'logging'.",
|
||||
),
|
||||
) -> dict:
|
||||
@@ -285,27 +181,34 @@ 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
|
||||
"""
|
||||
response = _get_hub_endpoint("check", "search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
checks = parse_json_response(response)
|
||||
try:
|
||||
response = prowler_hub_client.get("/check/search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
checks = response.json()
|
||||
|
||||
# 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"],
|
||||
# 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}",
|
||||
}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_check_details(
|
||||
check_id: NonBlankStr = Field(
|
||||
check_id: str = Field(
|
||||
description="The check ID to retrieve details for. Example: 's3_bucket_level_public_access_block'"
|
||||
),
|
||||
) -> dict:
|
||||
@@ -370,83 +273,83 @@ async def get_check_details(
|
||||
2. Use this tool with the check 'id' to get complete information including remediation guidance
|
||||
"""
|
||||
try:
|
||||
response = _get_hub_endpoint("check", check_id)
|
||||
response = prowler_hub_client.get(f"/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:
|
||||
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
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_check_code(
|
||||
provider_id: NonBlankStr = Field(
|
||||
provider_id: str = Field(
|
||||
description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
|
||||
),
|
||||
check_id: NonBlankStr = Field(
|
||||
check_id: str = Field(
|
||||
description="The check ID. Example: 's3_bucket_public_access'. Get IDs from `prowler_hub_list_checks` or `prowler_hub_search_checks`.",
|
||||
),
|
||||
) -> dict:
|
||||
@@ -460,54 +363,46 @@ async def get_check_code(
|
||||
"content": "Python source code of the check implementation"
|
||||
}
|
||||
"""
|
||||
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,
|
||||
}
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_check_fixer(
|
||||
provider_id: NonBlankStr = Field(
|
||||
provider_id: str = Field(
|
||||
description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
|
||||
),
|
||||
check_id: NonBlankStr = Field(
|
||||
check_id: str = 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 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.
|
||||
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.
|
||||
|
||||
Fixer code provides automated remediation that can fix security issues detected by checks.
|
||||
Use this to understand how to programmatically remediate findings.
|
||||
@@ -516,37 +411,40 @@ 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}"
|
||||
}
|
||||
"""
|
||||
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,
|
||||
}
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
# Compliance Framework Tools
|
||||
@@ -593,26 +491,33 @@ async def list_compliances(
|
||||
if provider:
|
||||
params["provider"] = ",".join(provider)
|
||||
|
||||
response = _get_hub_endpoint("compliance", params=params)
|
||||
response.raise_for_status()
|
||||
compliances = parse_json_response(response)
|
||||
try:
|
||||
response = prowler_hub_client.get("/compliance", params=params)
|
||||
response.raise_for_status()
|
||||
compliances = response.json()
|
||||
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
# 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}",
|
||||
}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def semantic_search_compliances(
|
||||
term: NonBlankStr = Field(
|
||||
term: str = Field(
|
||||
description="Search term. Examples: 'CIS', 'HIPAA', 'PCI', 'GDPR', 'SOC2', 'NIST'.",
|
||||
),
|
||||
) -> dict:
|
||||
@@ -637,26 +542,33 @@ async def semantic_search_compliances(
|
||||
]
|
||||
}
|
||||
"""
|
||||
response = _get_hub_endpoint("compliance", "search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
compliances = parse_json_response(response)
|
||||
try:
|
||||
response = prowler_hub_client.get("/compliance/search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
compliances = response.json()
|
||||
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
# 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}",
|
||||
}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_compliance_details(
|
||||
compliance_id: NonBlankStr = Field(
|
||||
compliance_id: str = 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:
|
||||
@@ -686,60 +598,63 @@ async def get_compliance_details(
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = _get_hub_endpoint("compliance", compliance_id)
|
||||
response = prowler_hub_client.get(f"/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:
|
||||
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
|
||||
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)}
|
||||
|
||||
|
||||
# Provider Tools
|
||||
@@ -768,25 +683,32 @@ async def list_providers() -> dict:
|
||||
]
|
||||
}
|
||||
"""
|
||||
response = _get_hub_endpoint("providers")
|
||||
response.raise_for_status()
|
||||
providers = parse_json_response(response)
|
||||
try:
|
||||
response = prowler_hub_client.get("/providers")
|
||||
response.raise_for_status()
|
||||
providers = response.json()
|
||||
|
||||
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}
|
||||
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)}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
async def get_provider_services(
|
||||
provider_id: NonBlankStr = Field(
|
||||
provider_id: str = 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:
|
||||
@@ -805,20 +727,24 @@ async def get_provider_services(
|
||||
"services": ["s3", "ec2", "iam", "rds", "lambda", ...]
|
||||
}
|
||||
"""
|
||||
response = _get_hub_endpoint("providers")
|
||||
response.raise_for_status()
|
||||
providers = parse_json_response(response)
|
||||
try:
|
||||
response = prowler_hub_client.get("/providers")
|
||||
response.raise_for_status()
|
||||
providers = response.json()
|
||||
|
||||
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", []),
|
||||
}
|
||||
|
||||
known = ", ".join(sorted(str(provider["id"]) for provider in providers))
|
||||
raise ToolError(
|
||||
f"Prowler has no provider with the ID '{provider_id}'. Available: {known}."
|
||||
)
|
||||
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)}
|
||||
|
||||
@@ -7,18 +7,11 @@ 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 (
|
||||
CredentialError,
|
||||
InvalidArgument,
|
||||
UpstreamInvalidResponse,
|
||||
_describe_failure,
|
||||
parse_json_response,
|
||||
)
|
||||
from prowler_mcp_server.lib.errors import InvalidArgument, _describe_failure
|
||||
from prowler_mcp_server.prowler_app.utils.api_client import (
|
||||
ProwlerAPIError,
|
||||
ProwlerAPIInvalidResponse,
|
||||
@@ -29,42 +22,6 @@ 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
|
||||
|
||||
|
||||
@@ -133,29 +90,6 @@ 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(
|
||||
@@ -165,19 +99,6 @@ 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."""
|
||||
|
||||
@@ -274,34 +195,3 @@ 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
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"""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 == []
|
||||
@@ -1,59 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,225 +0,0 @@
|
||||
"""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"}
|
||||
)
|
||||
@@ -1,102 +0,0 @@
|
||||
"""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,16 +312,17 @@ 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:
|
||||
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",
|
||||
},
|
||||
)
|
||||
result = 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 == []
|
||||
|
||||
|
||||
@@ -333,10 +334,14 @@ async def test_creating_a_jira_integration_rejects_an_empty_domain(
|
||||
],
|
||||
ids=["security-hub", "amazon-s3"],
|
||||
)
|
||||
async def test_a_rejected_creation_fails_with_the_api_reason(
|
||||
async def test_a_rejected_creation_is_reported_rather_than_raised(
|
||||
mcp_root_server, mock_api_client, mock_router, tool, arguments
|
||||
):
|
||||
"""A refused creation is a tool error, and it still carries the API's reason."""
|
||||
"""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.
|
||||
"""
|
||||
mock_router.add(
|
||||
"POST",
|
||||
INTEGRATIONS,
|
||||
@@ -345,8 +350,10 @@ async def test_a_rejected_creation_fails_with_the_api_reason(
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="already has this integration"):
|
||||
await client.call_tool(tool, arguments)
|
||||
result = await client.call_tool(tool, arguments)
|
||||
|
||||
assert result.data["status"] == "failed"
|
||||
assert "already has this integration" in result.data["error"]
|
||||
|
||||
|
||||
async def test_a_creation_with_no_id_back_warns_before_a_blind_retry(
|
||||
@@ -360,11 +367,12 @@ 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:
|
||||
with pytest.raises(Exception, match="did not return its ID"):
|
||||
await client.call_tool(
|
||||
"prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"}
|
||||
)
|
||||
result = 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}"]
|
||||
|
||||
|
||||
@@ -387,10 +395,12 @@ async def test_a_creation_whose_read_back_fails_still_hands_over_the_id(
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="Integration i1 was created"):
|
||||
await client.call_tool(
|
||||
"prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"}
|
||||
)
|
||||
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"]
|
||||
|
||||
|
||||
async def test_a_connection_check_that_cannot_run_is_not_reported_as_a_failure(
|
||||
@@ -532,12 +542,13 @@ 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:
|
||||
with pytest.raises(Exception, match=message):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "configuration": configuration},
|
||||
)
|
||||
result = 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()
|
||||
|
||||
|
||||
@@ -632,12 +643,13 @@ async def test_updating_a_jira_configuration_is_refused(
|
||||
stub_integration(mock_router, JIRA_ATTRIBUTES)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="do not accept a configuration"):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "configuration": {"domain": "other"}},
|
||||
)
|
||||
result = 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()
|
||||
|
||||
|
||||
@@ -648,12 +660,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:
|
||||
with pytest.raises(Exception, match="tenant-wide"):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "provider_ids": ["p1"]},
|
||||
)
|
||||
result = 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()
|
||||
|
||||
|
||||
@@ -671,12 +683,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:
|
||||
with pytest.raises(Exception, match="exactly one AWS provider"):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "provider_ids": provider_ids},
|
||||
)
|
||||
result = 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()
|
||||
|
||||
|
||||
@@ -696,12 +708,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:
|
||||
with pytest.raises(Exception, match="replaced as a whole"):
|
||||
await client.call_tool(
|
||||
"prowler_update_integration",
|
||||
{"integration_id": "i1", "credentials": credentials},
|
||||
)
|
||||
result = 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()
|
||||
|
||||
|
||||
@@ -739,14 +751,13 @@ async def test_replacing_jira_credentials_normalizes_the_domain(
|
||||
# ------------------------------------------------- delete and connection tools
|
||||
|
||||
|
||||
async def test_deleting_an_integration_confirms_it_happened(
|
||||
async def test_deleting_an_integration_reports_the_outcome_either_way(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Deletion is irreversible, so a success says so rather than staying silent.
|
||||
"""Deletion is irreversible, so both outcomes are stated explicitly.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
mock_router.add("DELETE", INTEGRATION, status=204)
|
||||
|
||||
@@ -755,23 +766,24 @@ async def test_deleting_an_integration_confirms_it_happened(
|
||||
"prowler_delete_integration", {"integration_id": "i1"}
|
||||
)
|
||||
|
||||
assert "i1 deleted successfully" in result.data["message"]
|
||||
assert "deleted" not in result.data
|
||||
assert result.data["deleted"] is True
|
||||
|
||||
|
||||
async def test_a_refused_deletion_fails_and_says_the_role_is_the_problem(
|
||||
async def test_a_failed_deletion_says_it_did_not_happen(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A 403 is the same answer for every tool, so `lib.errors` writes it."""
|
||||
"""`deleted: false` is the part the agent must not have to infer."""
|
||||
mock_router.add(
|
||||
"DELETE", INTEGRATION, status=403, json=jsonapi_error(403, "Permission denied.")
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="prowler_get_current_user"):
|
||||
await client.call_tool(
|
||||
"prowler_delete_integration", {"integration_id": "i1"}
|
||||
)
|
||||
result = await client.call_tool(
|
||||
"prowler_delete_integration", {"integration_id": "i1"}
|
||||
)
|
||||
|
||||
assert result.data["deleted"] is False
|
||||
assert "Permission denied." in result.data["message"]
|
||||
|
||||
|
||||
async def test_checking_a_connection_surfaces_why_it_failed(
|
||||
@@ -1014,32 +1026,6 @@ 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
|
||||
):
|
||||
|
||||