Compare commits

..
Author SHA1 Message Date
Alan Buscaglia 7598d1a636 feat(codex): add Prowler plugin
- Add the Codex marketplace and MCP-backed compliance workflow
- Document installation and environment-backed authentication
- Add packaging and parity coverage
2026-08-27 19:01:16 +02:00
Rubén De la Torre Vico 2b81fdcc04 fix(mcp): call the Mintlify search endpoint the documentation moved to (#12578) 2026-08-27 16:48:38 +02:00
Pablo Fernandez Guerra (PFE) db298c1d48 fix(ui): redirect the Slack OAuth callback relative to the browser's origin (#12577) 2026-08-27 16:13:36 +02:00
2877c3d6c0 fix(slack): handle scans that produce no findings (#12229)
Co-authored-by: Juhef <117518034+juheff@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 14:03:33 +02:00
ee64c17108 fix(kubernetes): return empty list when resource gather fails (#12225)
Co-authored-by: Juhef <117518034+juheff@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 13:55:46 +02:00
Pablo Fernandez Guerra (PFE)andalejandrobailo a610314eba fix(ui): complete Slack OAuth callback server-side to avoid router race (#12572)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-27 13:54:15 +02:00
Muhammad Ibrahimandpedrooot 301edea7ce feat(compliance): add Cyber Essentials 3.3 for Azure (#11588)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-27 13:31:18 +02:00
c89d900aae fix(iac): raise typed exceptions instead of sys.exit on provider failures (#12227)
Co-authored-by: Juhef <117518034+juheff@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 12:15:50 +02:00
Hugo Pereira Brito 4cfb4eeb96 fix(sdk): use system trust store for push-to-cloud (#12485) 2026-08-27 11:07:14 +01:00
Pedro Martínandalejandrobailo f19478f2f6 fix(compliance): discover universal frameworks from entry point (#12536)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-27 09:17:39 +02:00
Chethas DileepandDaniel Barranquero 2f11b16299 feat(github): add repository_default_workflow_permissions_read_only check (#12143)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 09:00:42 +02:00
Pablo Fernandez Guerra (PFE) 654d2c9f17 docs: document Slack channel destinations for alerts (#12496) 2026-08-27 08:53:07 +02:00
Chethas DileepandDaniel Barranquero 2721d42594 feat(github): add organization_actions_pull_request_approval_disabled check (#12394)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 08:25:32 +02:00
Daniel Barranquero bd1956446d fix(alibabacloud): read OSS bucket sub-resource configs via the SDK execute path (#12546) 2026-08-26 16:41:13 +02:00
Pablo Fernandez Guerra (PFE) d26d7cf91a feat(ui): show a rule's destinations in the alerts list (#12493) 2026-08-26 13:36:31 +02:00
Pablo Fernandez Guerra (PFE) 4c20bf1fac feat(ui): let alert rules target authorized Slack channels (#12492) 2026-08-26 12:55:49 +02:00
Chethas DileepandDaniel Barranquero 9dc53ffc60 feat(github): add organization_default_workflow_permissions_read_only check (#12122)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-26 12:21:49 +02:00
Alan Buscagliaandalejandrobailo 95642fb220 feat(ui): add Lighthouse request outcome feedback (#12419)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-26 11:34:40 +02:00
Pedro Martín 6449f3a592 fix(container): patch the high OpenSSL CVEs for container img (#12549) 2026-08-26 11:10:55 +02:00
Rubén De la Torre Vico 91e6cb798d feat(mcp): classify shared tool failures and stop relaying upstream bodies (#12531) 2026-08-26 11:06:07 +02:00
Rubén De la Torre Vico bcd37988b5 fix(mcp): patch the high openssl CVE in the container image (#12547) 2026-08-26 10:45:41 +02:00
Alejandro Bailo 7a64e1a1d1 feat(ui): add cancelled subscription variant to the trial sidebar banner (#12538) 2026-08-26 08:59:00 +02:00
Pablo Fernandez Guerra (PFE) 39c85ffb77 feat(ui): authorize multiple Slack destination channels (#12491) 2026-08-26 08:52:56 +02:00
Alex ChenandDaniel Barranquero 301ca50541 feat(alibabacloud): add oss_bucket_server_side_encryption_enabled check (#11981)
Signed-off-by: Alex Chen <l46983284@gmail.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-25 17:41:52 +02:00
Rubén De la Torre Vico f299e1d9ac fix(mcp): patch the two high sqlite CVEs in the container image (#12537) 2026-08-25 17:41:44 +02:00
João Mesquitaandpedrooot 465e35bf54 fix(compliance): correct AWS FSBP check mapping for IAM.9 and EKS.1 (#12372)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-25 17:38:33 +02:00
Pablo Fernandez Guerra (PFE) 829af2e3f7 docs: add Slack integration guide (#12438) 2026-08-25 15:44:34 +02:00
Hugo Pereira Brito 51c65bdc69 docs: restructure organization documentation (#12521) 2026-08-25 12:49:23 +01:00
Hugo Pereira Brito d0c088dd0a docs: explain AWS Organization account membership updates (#12512) 2026-08-25 12:47:23 +01:00
abidedavanaandDaniel Barranquero 411d112165 feat(alibabacloud): add oss_bucket_versioning_enabled check (#11913)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-25 13:03:25 +02:00
Gabrielandpedrooot 8a4cc8780d feat(kubernetes): include cluster name in compliance reports (#12506)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-25 11:08:04 +02:00
Pepe Fagoaga cd4d2a27e3 chore(lighthouse): rename not tested to not connected (#12523) 2026-08-25 09:21:18 +02:00
9898cf7364 feat(m365): add defender_domain_dmarc_records_published check (#11936)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: hdy2001 <56308320+hdy2001@users.noreply.github.com>
2026-08-24 16:49:36 +02:00
83d8cfa829 fix(aws): treat security groups on Batch compute environments as used (#12458)
Co-authored-by: hackertwinten <193916571+hackertwinten@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-24 16:18:56 +02:00
mintlify[bot] 5202a68cf0 docs: brand tone and writing style fixes (#12510)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-24 11:17:37 +02:00
Hugo Pereira Brito e3a3acc799 fix(api): upgrade sqlparse to 0.6.0 (#12509) 2026-08-24 08:47:29 +02:00
Pablo Fernandez Guerra (PFE) 3e000faa31 feat(ui): add Slack disconnect and revoked-credential recovery (#12437) 2026-08-21 12:42:47 +02:00
Jonathan NguyenandHugo P.Brito f39c92b8f8 feat(bedrock): add model artifact and guardrail grounding security checks for the AWS provider (#12459)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-21 10:50:34 +01:00
Pablo Fernandez Guerra (PFE) 823efc5ab1 feat(ui): pick a Slack channel and verify it (#12436) 2026-08-21 11:45:24 +02:00
Pablo Fernandez Guerra (PFE) 75d7fa5006 feat(ui): add Slack integration connect flow (#12435) 2026-08-21 10:49:46 +02:00
Prowler Botandprowler-bot db25484ccb feat(aws): Update regions for AWS services (#12472)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-20 16:02:56 +01:00
Johannes EnglerandHugo P.Brito 3da4209ee7 feat(stackit): add ske_cluster_no_public_endpoint check (#11943)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-20 13:01:01 +01:00
acb6ff0425 feat(providers/huaweicloud): add vpc_security_group_open_egress check (#12209)
Co-authored-by: tomitobio <tomitobio@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-20 10:52:13 +01:00
Pablo Fernandez Guerra (PFE) ba564af4f4 fix(ci): unblock the Python runtime download in blocked-egress jobs (#12490) 2026-08-20 10:48:16 +02:00
339 changed files with 25949 additions and 2585 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "prowler-plugins",
"description": "Prowler Cloud Security for Codex",
"owner": {
"name": "Prowler",
"email": "support@prowler.com"
},
"plugins": [
{
"name": "prowler",
"source": {
"source": "local",
"path": "./codex_plugins/prowler"
},
"description": "Prowler for Codex — cloud security and compliance skills powered by the Prowler MCP server.",
"category": "security",
"homepage": "https://prowler.com"
}
]
}
-5
View File
@@ -82,11 +82,6 @@ provider/vercel:
- any-glob-to-any-file: "prowler/providers/vercel/**"
- any-glob-to-any-file: "tests/providers/vercel/**"
provider/supabase:
- changed-files:
- any-glob-to-any-file: "prowler/providers/supabase/**"
- any-glob-to-any-file: "tests/providers/supabase/**"
provider/okta:
- changed-files:
- any-glob-to-any-file: "prowler/providers/okta/**"
-7
View File
@@ -185,13 +185,6 @@ modules:
- tests/providers/vercel/**
e2e: []
- name: sdk-supabase
match:
- prowler/providers/supabase/**
tests:
- tests/providers/supabase/**
e2e: []
# ============================================
# SDK - Lib modules
# ============================================
+1
View File
@@ -61,6 +61,7 @@ jobs:
api.github.com:443
github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
@@ -114,6 +114,8 @@ jobs:
egress-policy: block
allowed-endpoints: >
auth.docker.io:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
files.pythonhosted.org:443
ghcr.io:443
github.com:443
@@ -81,6 +81,8 @@ jobs:
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
+1
View File
@@ -35,6 +35,7 @@ jobs:
api.github.com:443
github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
@@ -57,6 +57,7 @@ jobs:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
+10 -1
View File
@@ -22,11 +22,20 @@ ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb0
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# 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
# (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 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 \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
+10 -1
View File
@@ -21,6 +21,14 @@ ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb0
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# 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
# (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 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 \
@@ -36,7 +44,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libtool \
libxslt1-dev \
python3-dev \
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -0,0 +1 @@
`openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs
+1
View File
@@ -0,0 +1 @@
`sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491
+2 -2
View File
@@ -61,7 +61,7 @@ dependencies = [
"cartography (==0.138.1)",
"gevent (==25.9.1)",
"werkzeug (==3.1.7)",
"sqlparse (==0.5.5)",
"sqlparse (==0.6.0)",
"fonttools (==4.62.1)",
"uvicorn-worker (==0.4.0)"
]
@@ -423,7 +423,7 @@ constraint-dependencies = [
"six==1.17.0",
"slack-sdk==3.39.0",
"sniffio==1.3.1",
"sqlparse==0.5.5",
"sqlparse==0.6.0",
"statsd==4.0.1",
"std-uritemplate==2.0.8",
"stevedore==5.6.0",
Generated
+5 -5
View File
@@ -339,7 +339,7 @@ constraints = [
{ name = "six", specifier = "==1.17.0" },
{ name = "slack-sdk", specifier = "==3.39.0" },
{ name = "sniffio", specifier = "==1.3.1" },
{ name = "sqlparse", specifier = "==0.5.5" },
{ name = "sqlparse", specifier = "==0.6.0" },
{ name = "statsd", specifier = "==4.0.1" },
{ name = "std-uritemplate", specifier = "==2.0.8" },
{ name = "stevedore", specifier = "==5.6.0" },
@@ -5040,7 +5040,7 @@ requires-dist = [
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
{ name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" },
{ name = "sqlparse", specifier = "==0.5.5" },
{ name = "sqlparse", specifier = "==0.6.0" },
{ name = "uuid6", specifier = "==2024.7.10" },
{ name = "uvicorn-worker", specifier = "==0.4.0" },
{ name = "uvloop", specifier = "==0.22.1" },
@@ -6049,11 +6049,11 @@ wheels = [
[[package]]
name = "sqlparse"
version = "0.5.5"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
]
[[package]]
@@ -0,0 +1,24 @@
{
"name": "prowler",
"displayName": "Prowler Cloud Security",
"version": "0.1.0",
"description": "Cloud security and compliance skills powered by the Prowler MCP server.",
"author": {
"name": "Prowler",
"email": "support@prowler.com",
"url": "https://prowler.com"
},
"homepage": "https://docs.prowler.com",
"repository": "https://github.com/prowler-cloud/prowler",
"license": "Apache-2.0",
"category": "security",
"keywords": [
"prowler",
"security",
"compliance",
"cloud-security",
"mcp"
],
"skills": "./skills/",
"mcpServers": "./.mcp.json"
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"prowler": {
"type": "http",
"url": "https://mcp.prowler.com/mcp",
"bearer_token_env_var": "PROWLER_API_KEY",
"headers": {
"User-Agent": "codex"
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
# Prowler for Codex
Prowler for Codex adds Prowler Cloud security and compliance skills and connects Codex to the Prowler MCP server.
## Prerequisites
- Codex CLI with plugin support. Check with `codex --version` and `codex plugin --help`.
- A Prowler Cloud API key. Create one in [Prowler Cloud](https://cloud.prowler.com).
## Install
Export the API key in the shell that launches Codex. Set `PROWLER_API_KEY` to the raw API key only, without the `Bearer` prefix. Codex adds the bearer prefix automatically. Do not add a real key to a repository, shell history, or shared configuration file.
```bash
export PROWLER_API_KEY=...
codex plugin marketplace add prowler-cloud/prowler --ref master
codex plugin add prowler@prowler-plugins
```
The marketplace does not install Prowler by default. The `codex plugin add` command explicitly installs it.
## Verify
```bash
codex plugin list --marketplace prowler-plugins
```
Start Codex from the same shell, then ask it to list your Prowler providers or to help triage a compliance framework.
## Update and Uninstall
Refresh the marketplace snapshot:
```bash
codex plugin marketplace upgrade prowler-plugins
```
Remove the plugin:
```bash
codex plugin remove prowler@prowler-plugins
```
If you no longer use the marketplace, remove it after uninstalling the plugin:
```bash
codex plugin marketplace remove prowler-plugins
```
## Environment Limitation
The Codex CLI reliably receives `PROWLER_API_KEY` when it starts from the shell where you exported it. Codex Desktop and IDE integrations may not inherit variables from your shell profile, so the plugin can fail to authenticate there even when it works in the CLI.
The Codex plugin workflow does not securely prompt for or store arbitrary API keys. If your Codex surface cannot inherit `PROWLER_API_KEY`, use the manual MCP setup in the [Prowler Codex guide](https://docs.prowler.com/user-guide/ai-agents/codex) instead.
@@ -0,0 +1,199 @@
---
name: prowler-framework-compliance-triage
description: Make a cloud account compliant with a security or industry framework using Prowler Cloud.
---
# Framework compliance
Iterative, interactive flow that takes a cloud account through setup, reporting, and remediation until it complies with the chosen security or industry framework.
## Checkpoints
This skill uses **checkpoints** to mark moments where you must stop, post a clear question or summary to the user, and wait for the reply before continuing. Each checkpoint is rendered like this:
> **Checkpoint — <name>**
>
> What to present, and what to wait for.
Treat every checkpoint as a hard stop:
- Do not skip a checkpoint because the user previously said "go ahead", "just do it", or similar. Confirmations are scoped to a single checkpoint and do not transfer to later ones.
- Do not bundle two checkpoints into one message. Post one, wait for the reply, then continue.
- Do not infer the user's answer from context or proceed on silence. Ask explicitly and wait.
- If a checkpoint is conditional (e.g. only fires when multiple accounts exist), evaluate the condition first; if it does not apply, continue without prompting.
- If the user's initial message already answers the question a checkpoint asks (e.g. "make my AWS subscription compliant with CIS using Terraform autonomously"), treat the checkpoint as satisfied for the parts they covered, and only ask for what is still missing.
## 1. Initial Prowler Cloud setup
> **Checkpoint — Provider and framework selection**
>
> If the user has not already specified both the provider and the framework, ask explicitly and wait for the answer. If they have specified them in their opening message, skip this checkpoint.
Confirm both are supported by the Prowler Hub MCP:
- Enumerate supported providers with `prowler_hub_list_providers`.
- Enumerate frameworks for the chosen provider with `prowler_hub_list_compliances`, passing the provider `id` as the only element of the `provider` input list.
If the framework is not supported, tell the user, suggest they request it or contribute it themselves, and end the flow. Otherwise continue.
### 1.1 Connect to Prowler Cloud
Verify the Prowler MCP connection by calling `prowler_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
For getting accurate information about configurations use `prowler_docs_search` to pull relevant instructions from the Prowler documentation.
### 1.2 Verify the provider is configured (or configure it)
Call `prowler_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
- **Provider not present.** Guide the user through adding and configuring it. Retrieve the relevant connection, credential, and permission instructions with `prowler_docs_search`.
- **Provider present but misconfigured** (missing credentials, insufficient permissions, etc.). Walk the user through fixing the configuration, pulling the relevant guidance with `prowler_docs_search`.
- **Provider present and configured.** Continue.
> **Checkpoint — Account selection** *(conditional: more than one account of the chosen provider is configured)*
>
> List the accounts with helpful detail (account name, uid, last scan date) and ask which one to use. Wait for the answer. If only one account exists, skip this checkpoint and use it.
### 1.3 Review compliance report for the provider account
The flow needs at least one completed scan with a compliance report available.
Look for a completed scan first: call `prowler_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
If no completed scan has a report, call `prowler_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
> **Checkpoint — Scan-in-progress decision** *(conditional: an in-progress scan was detected)*
>
> Tell the user a scan is already running and ask whether to wait for it to complete or start a fresh one. Wait for the answer.
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
When a scan is in progress (either pre-existing and elected to wait, or just triggered), stop the flow and ask the user to return when it's completed — restart this section to re-check the results.
## 2. Compliance report
Every iteration of the remediation loop reads and writes a single markdown file per provider account and framework, stored at `.prowler/compliance-<compliance_id>-<provider_uid>.md` relative to the current project root. Sanitize `<provider_uid>` to `[a-zA-Z0-9_-]` by replacing anything else with `-`. Create `.prowler/` if missing.
Across iterations, edit only: status tags on failed requirements and their findings, the per-requirement `Fix plan` / `Fix applied` sub-bullets added during sections 3.3–3.4, the **Global remediation approach** block, and the **Activity log** (append-only, newest on top). Requirement descriptions, finding IDs, and the entire **Manual review requirements** section are read-only after first render.
Status taxonomy for failed requirements and their findings:
- `[FAIL]` — failing in the latest scan.
- `[IN PROGRESS]` — picked up by section 3.3.
- `[FIXED-UNVERIFIED]` — remediation applied; not yet confirmed.
- `[PASS]` — passing in the latest scan (set when a rescan in section 3.5 confirms the fix).
- `[SKIPPED]` — user explicitly deferred.
### Report template
A fresh report is rendered like this (substituting values from the `prowler_get_compliance_framework_state_details` Prowler MCP tool response):
````markdown
# Compliance report: <compliance_id>
**Provider account**: <display name + uid>
**Scan ID**: <scan_id>
**Generated**: <ISO timestamp>
**Last update**: <ISO timestamp>
**Status**: <passed>/<total> passing (<pct>%) · <failed> failing · <manual_review> manual review
## Global remediation approach
<!-- Filled by section 3.1. -->
- **Primary tool**: _Terraform | Azure CLI | AWS CLI | web console | mixed_
- **Mode**: _Claude autonomous | Claude-assisted_
- **Notes**:
## Activity log
- <ISO timestamp> — Report initialized from scan `<scan_id>`.
## Failed requirements
### <code> — [FAIL]
**Description**: <text>
**Findings** (<n>):
- [FAIL] `<finding_id>`
## Manual review requirements
- **<code>** — [PENDING]: <description>
````
### 2.1 Generate or refresh the report
Resolve the report path for the current `compliance_id` and provider account.
If the file does not exist, call `prowler_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
If the file exists, read it and compare its `Scan ID` to the target scan from section 1.3. When the scan matches, reuse the file and summarize remaining `[FAIL]` and `[IN PROGRESS]` items in chat.
> **Checkpoint — Report refresh** *(conditional: the file's `Scan ID` differs from the current target scan)*
>
> Tell the user the report on disk was generated from a different scan and ask whether to refresh it from the new scan. Wait for the answer.
On confirmation, regenerate the failed-requirements section from the new `prowler_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
Once the file is current, surface the top failing requirements in chat: sort by finding count descending, show the top 5 with their codes and counts, and point to the file path for the full list.
## 3. Remediation loop
### 3.1 Define the global remediation approach
Two modes are available:
- **Claude-assisted** (default when the user has not specified): per-requirement confirmation. For each requirement Claude shows the target resource, exact commands, side effects, and reversibility, then waits for explicit go-ahead before applying.
- **Claude autonomous**: no per-requirement gate, but Claude still presents one batch-level fix plan up front (§3.2) and waits for a single confirmation, and pauses if a finding looks not applicable, requires a paid feature, or has wide blast radius (breaks dev workflow, forces collaborator changes, is hard to reverse).
If the user phrases their request as "just do it" or similar, treat that as autonomous **with** the batch-plan confirmation still required — the confirmation is a property of the skill, not the user's verbosity preference.
> **Checkpoint — Global remediation approach**
>
> Ask the user which tool to use for fixes (Terraform, gh / az / aws CLI, web console, mixed...) and which mode to operate in. Wait for the answer before continuing. This checkpoint is non-negotiable: never assume a default tool, and never assume autonomous mode.
Once answered, write the values into the **Global remediation approach** block of the report file.
> **Checkpoint — Overwriting an existing approach** *(conditional: the block is already populated from a previous session)*
>
> Show the previous values and the new ones, and ask the user to confirm before overwriting. Wait for the answer.
### 3.2 Present the batch fix plan *(autonomous mode only)*
In **assisted** mode, skip this section — the per-requirement gate in §3.3 confirms each fix as it comes up. Only run §3.2 in **autonomous** mode, where the loop will otherwise apply fixes without further input.
Before touching anything, post a single chat summary covering every `[FAIL]` requirement:
- Group findings that share a fix (e.g. ten branch-protection requirements satisfied by one PUT call → present as one group).
- For each group: target resource, exact tool calls, side effects, reversibility.
- Call out findings that look **not applicable** to this target (e.g. an Organization-only check evaluated against a User account, a feature gated by a paid plan, a resource type the user doesn't have) and propose `[SKIPPED]` with the reason.
- Call out findings that require manual user action Claude cannot perform.
> **Checkpoint — Batch fix plan approval** *(conditional: autonomous mode)*
>
> Post the grouped plan and wait for explicit confirmation. Do not start any fix before the user replies.
Once approved, the loop proceeds through the batch without further prompts unless something deviates from the approved plan.
### 3.3 Pick the first FAIL requirement and inspect its findings
Pick the first `[FAIL]` requirement at the top of the failed-requirements section. Move its status and every finding under it to `[IN PROGRESS]`, and add a `**Fix plan**:` sub-bullet describing what will be done.
Call `prowler_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
If a finding does not apply to the target resource (Organization-only check on a User account, paid-tier feature, missing resource type, etc.), set the requirement status to `[SKIPPED]` with the reason, log it in the activity log, and move on without attempting the fix — even if it was missed during §3.2.
> **Checkpoint — Per-requirement approval** *(conditional: assisted mode)*
>
> Post the per-requirement plan in chat — resource, command, side effects, reversibility — and wait for confirmation before moving to §3.4. In **autonomous** mode, post the plan for transparency but proceed unless it deviates from the batch plan agreed in §3.2.
### 3.4 Diagnose, fix, verify
Read the remediation guidance returned in §3.3, identify the root cause, and apply the fix using the tool defined in the **Global remediation approach** block. After applying, verify via the same tool that applied the fix or via a provider API call when applicable. If the re-read shows the change did not land, leave the status at `[IN PROGRESS]`, surface the error to the user, and stop the loop for this requirement. On post-fix verification failure, record the failure in the activity log and set the requirement status back to `[FAIL]` so the next loop can retry or choose another remediation.
When the change is in place, append a `**Fix applied**: <tool, summary, refs>` sub-bullet to the requirement, move each fixed finding to `[FIXED-UNVERIFIED]`, and add one activity-log entry describing the change. If no programmatic verification was possible (e.g. web console action), note in the activity log that confirmation depends on the rescan in §3.5.
### 3.5 Loop
Move to the next `[FAIL]` requirement and repeat from section 3.3.
> **Checkpoint — Rescan trigger** *(conditional: no `[FAIL]` requirements remain; all are `[FIXED-UNVERIFIED]` or `[SKIPPED]`)*
>
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
On confirmation, trigger the rescan. When it completes, restart section 2.1 with the carry-forward path — requirements no longer in the new FAIL list move to `[PASS]`, anything still failing reverts to `[FAIL]` with the previous fix attempt visible in the activity log.
+57 -13
View File
@@ -120,14 +120,14 @@ class NewFeatureTools(BaseTool):
Returns complete feature details including configuration and metadata.
"""
try:
response = await self.api_client.get(f"/api/v1/features/{feature_id}")
return DetailedFeature.from_api_response(response["data"]).model_dump()
except Exception as e:
self.logger.error(f"Failed to get feature {feature_id}: {e}")
return {"error": str(e), "status": "failed"}
response = await self.api_client.get(f"/api/v1/features/{feature_id}")
return DetailedFeature.from_api_response(response["data"]).model_dump()
```
There is no `try`/`except` here on purpose. A failed request raises, and
[Error Handling](#error-handling) explains what turns that raise into a message
the agent can act on.
### Step 2: Create the Models
Create corresponding models in `prowler_app/models/`:
@@ -369,18 +369,62 @@ async def search_items(self, status: str = Field(...)) -> dict:
### Error Handling
Return structured error responses instead of raising exceptions:
**Raise, never return.** A returned `{"error": ...}` dict is reported to the
client as `isError: false` -- a *successful* tool call whose payload happens to
mention a failure. Clients and models read that as success. A raised exception
becomes a spec-correct tool execution error instead.
The common case therefore needs no handler at all:
```python
async def get_item(self, item_id: str) -> dict:
try:
response = await self.api_client.get(f"/api/v1/items/{item_id}")
return DetailedItem.from_api_response(response["data"]).model_dump()
except Exception as e:
self.logger.error(f"Failed to get item {item_id}: {e}")
return {"error": str(e), "status": "failed"}
response = await self.api_client.get(f"/api/v1/items/{item_id}")
return DetailedItem.from_api_response(response["data"]).model_dump()
```
`prowler_mcp_server/lib/errors.py` classifies the failures every tool shares --
a rejected credential, a missing permission, a rate limit, an outage, an
unreachable API, a bad argument -- and gives each one a message that says what
went wrong and what to do about it. Anything it does not recognise is masked,
because `mask_error_details=True` is set on every sub-server and upstream
response bodies must never be replayed into a model's context.
Three ways to raise, in the order to reach for them:
```python
from fastmcp.exceptions import ToolError
from prowler_mcp_server.lib.errors import InvalidArgument
# 1. An argument this server rejected before any request went out. The message
# is repeated to the agent verbatim, so write it for one to read.
if not 1 <= page_size <= 1000:
raise InvalidArgument("page_size must be between 1 and 1000.")
# 2. A request the API answered or never answered: let it propagate untouched.
# `ProwlerAPIError` and `ProwlerAPIUnreachable` are what the classifier keys
# on, and the second one is what stops a retry from duplicating a write.
response = await self.api_client.get(f"/api/v1/items/{item_id}")
data = response["data"]
# 3. A sentence the classifier cannot know -- a resource name, a precondition,
# the next tool to call. NOTE the absent `from` clause: it is what marks the
# message as already final. With `from e` the classifier would replace it.
if not data:
raise ToolError(
f"No item with the ID {item_id!r} exists. Use prowler_list_items to "
"find a valid one."
)
```
The one thing that still *returns* rather than raises is a write whose outcome is
genuinely unknown. `prowler_send_findings_to_jira` is the worked example: work
items are created one at a time and Prowler cannot delete them, so a dispatch
that stopped halfway answers with a result object carrying
`safe_to_retry: false`. "This may have been applied" is a fact about the world,
not an error, and squashing it into one loses the only thing that stops a retry
from duplicating the write.
### Parameter Descriptions
Use Pydantic `Field()` with clear descriptions. This also helps LLMs understand
+1 -1
View File
@@ -107,7 +107,7 @@ Once you have decided the provider you want or need to add to Prowler, the next
- **SDK Providers**: Low complexity. You have mature examples like AWS, Azure, GCP, Kubernetes, etc. that you can leverage to implement your provider.
- **API Providers**: Medium complexity. You need to implement the authentication and session management, and the API calls to the provider. You now have NHN and MongoDB Atlas as example to follow.
- **Tool/Wrapper Providers**: High complexity. You need to implement the argument/output mapping to the provider and handle problems that the tool/wrapper may have. You now have IAC and the PowerShell wrapper as example to follow.
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and GitHub (PyGithub SDK + graphql API requests) as examples.
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and GitHub (PyGithub SDK + graphql API requests) as examples.
### Determining Regional vs Non-Regional Architecture
+1 -1
View File
@@ -525,7 +525,7 @@ with mock.patch(
):
```
As demonstrated in the code above, mocking both the AWS audit information and all utilized services is mandatory for proper test execution.
As demonstrated in the code above, mocking both the AWS audit information and all used services is mandatory for proper test execution.
#### Patching vs. Importing
+12 -8
View File
@@ -182,7 +182,8 @@
"pages": [
"user-guide/tutorials/prowler-app-s3-integration",
"user-guide/tutorials/prowler-app-security-hub-integration",
"user-guide/tutorials/prowler-app-jira-integration"
"user-guide/tutorials/prowler-app-jira-integration",
"user-guide/tutorials/prowler-app-slack-integration"
]
},
{
@@ -301,6 +302,15 @@
{
"group": "Providers",
"pages": [
{
"group": "Organizations",
"pages": [
"user-guide/organizations",
"user-guide/providers/aws/organizations",
"user-guide/providers/gcp/organization",
"user-guide/providers/azure/management-groups"
]
},
{
"group": "Alibaba Cloud",
"pages": [
@@ -330,6 +340,7 @@
"user-guide/providers/azure/getting-started-azure",
"user-guide/providers/azure/authentication",
"user-guide/providers/azure/use-non-default-cloud",
"user-guide/providers/azure/management-groups",
"user-guide/providers/azure/subscriptions",
"user-guide/providers/azure/resource-groups",
"user-guide/providers/azure/create-prowler-service-principal"
@@ -465,13 +476,6 @@
"user-guide/providers/stackit/authentication"
]
},
{
"group": "Supabase",
"pages": [
"user-guide/providers/supabase/getting-started-supabase",
"user-guide/providers/supabase/authentication"
]
},
{
"group": "Vercel",
"pages": [
+1 -1
View File
@@ -22,7 +22,7 @@ Google Cloud Security Command Center (Cloud SCC) is a centralized security and r
- **GCP-Centric:** While Cloud SCC is powerful within the GCP ecosystem, it is primarily focused on GCP and does not natively extend to multi-cloud environments without additional tools or connectors.
- **Cost Considerations:** As a managed service within GCP, costs can scale with the amount of data ingested and the complexity of the environment, especially as additional features or higher volumes of data are utilized.
- **Cost Considerations:** As a managed service within GCP, costs can scale with the amount of data ingested and the complexity of the environment, especially as additional features or higher volumes of data are used.
- **Dependency on GCP Services:** Cloud SCC's capabilities depend on other GCP services being enabled, such as Security Health Analytics and Web Security Scanner, which may increase overall complexity and cost.
@@ -45,7 +45,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.13`. Prowler i
_Requirements_:
* Have `docker` installed: https://docs.docker.com/get-docker/.
* In the command below, change `-v` to your local directory path in order to access the reports.
* In the command below, change `-v` to your local directory path to access the reports.
* AWS, GCP, Azure and/or Kubernetes credentials
_Commands_:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

-1
View File
@@ -72,7 +72,6 @@ Prowler supports a wide range of providers organized by category:
| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI |
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | CLI |
| [Supabase](/user-guide/providers/supabase/getting-started-supabase) | [Contact us](https://prowler.com/contact) | Organizations / Members | CLI |
| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | UI, API, CLI |
### Kubernetes
+1
View File
@@ -92,6 +92,7 @@ li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::
li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-slack-integration"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after,
+89 -88
View File
@@ -5,104 +5,106 @@ sidebarTitle: "Codex / ChatGPT"
Connect [OpenAI Codex](https://learn.chatgpt.com/docs/extend/mcp) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so Codex can query findings, inspect checks, and manage your Prowler providers.
## Which Codex Surfaces Work
## Preferred Setup: Install the Prowler Plugin
Codex keeps MCP servers in one file, `~/.codex/config.toml`. You can set it up from either the **Codex / ChatGPT desktop app** or the **Codex CLI** — both write to that same file, so pick whichever you already use.
The Prowler plugin is the recommended setup for Codex CLI. It adds Prowler Cloud security and compliance skills and configures the Prowler MCP Server with the required `User-Agent` header.
| Surface | Set it up here | Notes |
|---------|----------------|-------|
| **[Codex / ChatGPT desktop app](https://learn.chatgpt.com/docs/app)** (macOS, Windows) | ✅ Yes | **Settings → MCP servers** |
| **Codex CLI** (terminal) | ✅ Yes | `codex mcp` commands |
| **Codex IDE extension** (VS Code) | Inherits | Works automatically once the app or CLI is configured |
| **ChatGPT on the web** | ❌ No | Does not read local Codex configuration |
<Note>
**Codex and ChatGPT share one desktop app.** Since July 2026 the standalone Codex app and the ChatGPT desktop app are the same application: Codex is a dedicated coding surface inside it, alongside Chat and Work. If you already had the Codex app, updating turns it into the new ChatGPT desktop app and it still opens in Codex. Either way, this guide applies.
Not to be confused with **ChatGPT Classic**, the name given to the previous-generation ChatGPT desktop app.
</Note>
<Note>
**Configure once, use everywhere.** The Codex documentation states that the ChatGPT desktop app, Codex CLI, and IDE extension "share this configuration. Once you configure your MCP servers, you can switch among those clients without redoing setup." Set the server up in the app or the CLI and the IDE extension picks it up with no extra work.
</Note>
| Surface | Recommended Setup | Notes |
|---------|-------------------|-------|
| **Codex CLI** | Prowler plugin | Reliably inherits the API key from the launching shell. |
| **Codex / ChatGPT desktop app** | Plugin when supported, otherwise [manual MCP setup](#manual-mcp-setup-advanced-or-fallback) | May require configuring the application launch environment. |
| **Codex IDE extension** | Plugin when supported, otherwise [manual MCP setup](#manual-mcp-setup-advanced-or-fallback) | May require configuring the IDE launch environment. |
| **ChatGPT on the web** | Not supported | Does not read local Codex configuration. |
## Prerequisites
- **The Codex / ChatGPT desktop app, or Codex CLI 0.46.0 or later.** Remote MCP servers over streamable HTTP were added to the CLI in 0.46.0 — check with `codex --version` and upgrade if needed.
- **Codex CLI with plugin support.** Check with `codex --version` and `codex plugin --help`.
- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
## Step 1: Get Your Prowler API Key
Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
Create an API key in Prowler Cloud and copy it. The key is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
## Step 2: Add the Prowler MCP Server
## Step 2: Set the API Key Environment Variable
The Prowler MCP Server needs two request headers: `Authorization` to authenticate you, and `User-Agent` because Codex does not send one by default.
Export the key in the shell that launches Codex. Do not add a real key to a repository, shell history, or shared configuration file.
Each tab below is a complete setup — follow the one that matches the surface you use.
```bash
export PROWLER_API_KEY="<your-Prowler-Cloud-API-key>"
```
<Tabs>
<Tab title="Codex / ChatGPT desktop app">
1. Open **Settings** and select **Plugins → MCPs**
2. Click **Add server**
3. Enter `prowler` as the name and choose type **Streamable HTTP**
4. Enter the URL `https://mcp.prowler.com/mcp`
5. Add two headers:
| Header | Value |
|--------|-------|
| `Authorization` | `Bearer pk_your_api_key_here` |
| `User-Agent` | `codex` |
6. Save the server
<Frame>
<img src="/images/prowler-mcp/codex/codex-app-mcp-servers.png" alt="Codex / ChatGPT desktop app Settings showing the MCP servers panel with the Add server dialog and both headers filled in" />
</Frame>
<Note>
**Enter the key directly here rather than using an environment variable.** Codex can read credentials from an environment variable, but desktop applications do not reliably inherit variables exported in a shell profile — on macOS an app launched from Finder or the Dock typically sees none of them. Pasting the key into the dialog is the approach that works consistently in the app.
</Note>
<Warning>
**This stores your API key in plain text** in `~/.codex/config.toml`. Treat that file accordingly: exclude it from dotfile repositories and config sync, and create the key from an account with the minimum permissions you need so its exposure is limited. Revoke and re-issue the key in Prowler Cloud if the file is ever shared.
</Warning>
</Tab>
<Tab title="Codex CLI">
Register the server:
```bash
codex mcp add prowler --url https://mcp.prowler.com/mcp
```
Codex confirms with `Added global MCP server 'prowler'.`
Then add both headers by hand, since `codex mcp add` has no flag for headers. Open `~/.codex/config.toml` and complete the entry:
```toml
[mcp_servers.prowler]
url = "https://mcp.prowler.com/mcp"
http_headers = { Authorization = "Bearer pk_your_api_key_here", "User-Agent" = "codex" }
```
<Note>
**Write the key literally rather than using an environment variable.** This is the form that works across every Codex surface. All of them read this same file, but only the CLI reliably sees variables exported in your shell profile — see the warning below.
</Note>
<Warning>
**This stores your API key in plain text** in `~/.codex/config.toml`. Treat that file accordingly: exclude it from dotfile repositories and config sync, and create the key from an account with the minimum permissions you need so its exposure is limited. Revoke and re-issue the key in Prowler Cloud if the file is ever shared.
</Warning>
</Tab>
</Tabs>
Restart Codex once you are done.
`PROWLER_API_KEY` must contain the raw API key, without the `Bearer ` prefix. The plugin adds that prefix when it sends the `Authorization` header.
<Note>
**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
**Codex CLI reliably inherits this variable** when it starts from the shell where you exported it. Codex Desktop and IDE integrations may not inherit variables from your shell profile. Configure the desktop application or IDE launch environment with `PROWLER_API_KEY` when you use those surfaces.
</Note>
## Step 3: Verify the Connection
## Step 3: Install the Plugin
Add the Prowler marketplace, then install the Prowler plugin:
```bash
codex plugin marketplace add prowler-cloud/prowler --ref master
codex plugin add prowler@prowler-plugins
```
The marketplace does not install Prowler by default. The `codex plugin add` command explicitly installs it.
## Step 4: Verify the Plugin
```bash
codex plugin list --marketplace prowler-plugins
```
Start Codex from the same shell, then ask it to list your Prowler providers or to help triage a compliance framework.
## Manual MCP Setup: Advanced or Fallback
Use this setup only when the plugin is unavailable for your Codex surface or when you need a custom MCP configuration. The manual configuration must include the `User-Agent` header because Codex does not send one by default.
### Codex CLI
Register the server:
```bash
codex mcp add prowler --url https://mcp.prowler.com/mcp
```
Then update `~/.codex/config.toml` because `codex mcp add` has no flag for the required header:
```toml
[mcp_servers.prowler]
url = "https://mcp.prowler.com/mcp"
bearer_token_env_var = "PROWLER_API_KEY"
http_headers = { "User-Agent" = "codex" }
```
This manual CLI configuration uses the same secure environment-backed contract as the plugin: `PROWLER_API_KEY` holds the raw key, and Codex adds the `Bearer ` prefix.
### Codex / ChatGPT Desktop App
First configure `PROWLER_API_KEY` in the desktop application's launch environment. An API key exported only in a shell profile may not be available when the app starts from Finder, the Dock, or a launcher.
If you cannot configure the launch environment, add the server manually:
1. Open **Settings** and select **Plugins → MCPs**
2. Click **Add server**
3. Enter `prowler` as the name and choose type **Streamable HTTP**
4. Enter the URL `https://mcp.prowler.com/mcp`
5. Add the `User-Agent` header with the value `codex`
6. If the app supports a reference to its launch environment, use `PROWLER_API_KEY`. Otherwise, add an `Authorization` header with `Bearer ` followed by the API key.
<Warning>
A literal `Authorization` header stores the API key in plain text in `~/.codex/config.toml`. Use it only as a last resort, exclude the file from dotfile repositories and configuration sync, and revoke and re-issue the key if the file is shared.
</Warning>
Restart Codex after installing the plugin or changing the MCP configuration.
<Note>
**Local server:** Replace the URL with your own HTTP endpoint. Keep the same authentication and `User-Agent` configuration.
</Note>
## Step 5: Verify the Connection
Run `/mcp` in the app or in a CLI session to list connected servers and their tools.
@@ -121,7 +123,7 @@ codex mcp get prowler # full entry, header values masked
**Verify rather than assume.** Codex silently ignores unrecognized keys in `config.toml` — a misspelled key name produces no error at all, and the server simply never receives your credentials. Always confirm with `codex mcp get prowler` after editing the file by hand.
</Warning>
## Step 4: Start Using Prowler MCP
## Step 6: Start Using Prowler MCP
Ask Codex questions that use the Prowler tools:
@@ -145,15 +147,14 @@ Codex reports a handshake failure on startup, with an HTML error page rather tha
<head><title>403 Forbidden</title></head>
```
The `User-Agent` header is missing. Codex's HTTP client does not send one, and requests without it are rejected before reaching the MCP server. Note this is a **403**, not a 401 — so it is not an API key problem. Add the header as shown in [Step 2](#step-2-add-the-prowler-mcp-server); the value itself does not matter, only that the header is present.
The `User-Agent` header is missing. Codex's HTTP client does not send one, and requests without it are rejected before reaching the MCP server. Note this is a **403**, not a 401 — so it is not an API key problem. The plugin adds this header automatically. For a manual configuration, add it as shown in [Manual MCP Setup](#manual-mcp-setup-advanced-or-fallback); the value itself does not matter, only that the header is present.
### Authentication Fails With 401
- Run `codex mcp get prowler` and confirm the entry has the headers you expect. Values are masked, but a missing header shows as `-`.
- If you used a literal header, confirm the value starts with `Bearer ` and contains the full key.
- **If it works in the CLI but fails in the desktop app or the VS Code extension, you are almost certainly using an environment variable.** Those surfaces do not inherit your shell profile. Switch that entry to a literal `Authorization` header as shown in [Step 2](#step-2-add-the-prowler-mcp-server).
- If you use an environment variable, verify it is set in the environment Codex was launched from: `echo $PROWLER_API_KEY`.
- With `env_http_headers` the variable must include the `Bearer ` prefix. With `bearer_token_env_var` it must **not** — Codex adds the prefix itself.
- For a manual configuration, run `codex mcp get prowler` and confirm the entry has the headers you expect. Values are masked, but a missing header shows as `-`.
- Verify the Codex process has `PROWLER_API_KEY` in its launch environment without printing the key: `test -n "$PROWLER_API_KEY" && echo "PROWLER_API_KEY is set"`.
- `PROWLER_API_KEY` must contain the raw key without `Bearer `. The plugin and the `bearer_token_env_var` manual configuration add the prefix automatically.
- If the CLI works but Codex Desktop or an IDE fails, configure `PROWLER_API_KEY` in that application's launch environment. Shell-profile exports may not be inherited. Use a literal `Authorization` header only as the last-resort desktop fallback described in [Manual MCP Setup](#manual-mcp-setup-advanced-or-fallback).
- Confirm the key has not been revoked in Prowler Cloud.
### Server Not Listed
@@ -6,7 +6,7 @@ In certain organizations, the severity of specific checks might differ from the
The custom metadata option offers a means to override default metadata set by Prowler.
You can utilize `--custom-checks-metadata-file` followed by the path to your custom checks metadata YAML file.
You can use `--custom-checks-metadata-file` followed by the path to your custom checks metadata YAML file.
## Available Fields
+1 -1
View File
@@ -99,7 +99,7 @@ def get_table(data):
## S3 Integration
If you are using Prowler Cloud with the S3 integration or that integration from Prowler CLI and you want to use your data from your S3 bucket, you can run the following command in order to load the dashboard with the new files:
If you are using Prowler Cloud with the S3 integration or that integration from Prowler CLI and you want to use your data from your S3 bucket, you can run the following command to load the dashboard with the new files:
```sh
aws s3 cp s3://<your-bucket>/output/csv ./output --recursive
+58
View File
@@ -0,0 +1,58 @@
---
title: 'Organizations Across Cloud Providers'
description: 'Understand organization hierarchies and onboarding across AWS, Google Cloud, and Azure'
---
Cloud providers use organization-level hierarchies to group accounts, projects, or subscriptions and apply access and governance consistently. Prowler uses these hierarchies to discover cloud targets and help configure multi-account or multi-project scanning.
This guide explains the shared lifecycle and the differences between AWS Organizations, Google Cloud organizations, and Azure Management Groups. Use the provider-specific guides for commands, permissions, and limitations.
## Organization Lifecycle
Organization-level onboarding generally follows these steps:
1. **Identify the hierarchy:** Locate the organization, management account, management group, folder, organizational unit, or equivalent parent node in the cloud provider.
2. **Grant access:** Assign the provider permissions required to enumerate the hierarchy and read the resources that Prowler scans.
3. **Discover members:** Use Prowler to retrieve accounts, projects, or subscriptions under the selected hierarchy.
4. **Select scan targets:** Choose the cloud targets to connect or scan. Discovery does not necessarily make every discovered target a Prowler provider.
5. **Test access:** Confirm that Prowler can authenticate to each selected target and read its resources.
6. **Scan and maintain:** Run scans, review findings, and repeat discovery when the provider hierarchy changes.
<Note>
Organization membership changes are not automatically synchronized in every Prowler workflow. Follow the provider-specific guide to learn when manual rediscovery is required.
</Note>
## Capability Matrix
| Capability | AWS Organizations | Google Cloud organization | Azure Management Groups |
| --- | --- | --- | --- |
| Hierarchy members | AWS accounts grouped in organizational units (OUs) | Projects grouped in folders and nested folders | Subscriptions grouped in management groups |
| Organization-level discovery | Supported through AWS Organizations APIs | Supported through the Cloud Asset API | Supported through Azure management-group and subscription APIs |
| Primary scan target | AWS account | Google Cloud project | Azure subscription |
| Common organization-level permission | IAM role in the management or delegated administrator account | Cloud Asset Viewer or Cloud Asset Owner at the organization node | Appropriate Azure role assignment at the management-group or subscription scope |
| Provider-specific onboarding | AWS account discovery and optional StackSet role deployment | Project discovery under an organization ID | Subscription discovery under a management group; role assignments inherit to subscriptions |
| Membership maintenance | Repeat the discovery flow when accounts are added or removed | Re-run organization discovery when projects or folders change | Refresh discovery when subscriptions move between management groups |
## Provider Guides
### AWS Organizations
The [AWS Organizations guide](/user-guide/providers/aws/organizations) covers account details, delegated administration, IAM roles, CloudFormation StackSets, and CLI scanning. For Prowler Cloud onboarding, see [AWS Organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-aws-organizations).
### Google Cloud Organization
The [Google Cloud organization guide](/user-guide/providers/gcp/organization) covers scanning projects under an organization ID, organization-level permissions, and Cloud Asset API requirements. For Prowler Cloud onboarding, see [Google Cloud organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-gcp-organizations).
### Azure Management Groups
The [Azure Management Groups guide](/user-guide/providers/azure/management-groups) covers hierarchy setup, role assignment, subscription scope, and Azure-specific limitations. For Prowler Cloud onboarding, see [Azure Management Groups in Prowler Cloud](/user-guide/tutorials/prowler-cloud-azure-management-groups).
## Scope Boundaries
The organization concepts in this guide refer only to cloud-provider resource hierarchies:
- **GitHub organizations** group repositories and GitHub resources. They are a separate provider concept and are not part of AWS, Google Cloud, or Azure organization discovery.
- **MongoDB Atlas organizations** group Atlas projects and teams. They use a separate provider API and authentication model.
- **Prowler Cloud organizations** are internal tenants that isolate providers, scans, findings, users, and permissions. They are not the same as a cloud-provider organization and do not replace one.
Choose the guide that matches the hierarchy being configured, then use the relevant Prowler Cloud or CLI workflow for the scan targets.
@@ -64,7 +64,7 @@ This method grants permanent access and is the recommended setup for production
7. Click "Submit" to deploy the stack
![Click on submit](/images/providers/submit-third-page.png)
![Click Submit](/images/providers/submit-third-page.png)
</Tab>
<Tab title="Terraform">
To provision the scan role using Terraform:
@@ -2,7 +2,7 @@
title: 'Scanning Multiple AWS Accounts with Prowler'
---
Prowler enables security scanning across multiple AWS accounts by utilizing the [Assume Role feature](/user-guide/providers/aws/role-assumption) and [integration with AWS Organizations feature](/user-guide/providers/aws/organizations).
Prowler enables security scanning across multiple AWS accounts by using the [Assume Role feature](/user-guide/providers/aws/role-assumption) and [integration with AWS Organizations feature](/user-guide/providers/aws/organizations).
This approach allows execution from a single account with permissions to assume roles in the target accounts.
@@ -12,6 +12,8 @@ See [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations) i
Prowler can integrate with AWS Organizations to manage the visibility and onboarding of accounts centrally.
For the cross-provider organization lifecycle and capability comparison, see [Organizations Across Cloud Providers](/user-guide/organizations).
When trusted access is enabled with the Organization, Prowler can discover accounts as they are created and even automate deployment of the Prowler Scan IAM Role.
> ℹ️ Trusted access can be enabled in the Management Account from the AWS Console under **AWS Organizations → Settings → Trusted access for AWS CloudFormation StackSets**.
@@ -0,0 +1,53 @@
---
title: 'Azure Management Groups in Prowler'
---
Azure Management Groups provide a hierarchy above subscriptions. They allow Azure role assignments and governance policies to apply to multiple subscriptions through a shared scope.
For the cross-provider concepts and lifecycle, see [Organizations Across Cloud Providers](/user-guide/organizations).
## Azure Hierarchy
Azure resources are organized in the following order:
1. Tenant
2. Management groups
3. Subscriptions
4. Resource groups
5. Resources
Prowler scans Azure subscriptions. Management groups help organize those subscriptions and provide a scope where permissions can be assigned, but a management group is not itself a scan target.
## Create a Management Group
To create a management group, follow the [official Azure guide](https://learn.microsoft.com/en-us/azure/governance/management-groups/create-management-group-portal).
![Create management group](/images/create-management-group.gif)
After creating the management group, add the subscriptions that Prowler should access and scan.
![Add Subscription to Management Group](/images/add-sub-to-management-group.gif)
## Assign Roles
Assign the roles required by Prowler at the management-group scope instead of assigning them separately to every subscription. Role assignments at a management group can inherit to its child subscriptions, subject to Azure role-assignment and inheritance rules.
Use the [subscription scope permissions](/user-guide/providers/azure/authentication#subscription-scope-permissions) guide to identify the permissions required for scans. The identity used by Prowler must be able to read the management-group hierarchy and access each subscription selected for scanning.
## Subscription Scope
Management groups organize subscriptions, but Azure scan results remain scoped to individual subscriptions:
- Prowler Cloud scans one subscription per scan.
- Prowler CLI can scan multiple subscriptions by using the `--subscription-ids` option.
- A subscription must be accessible to the configured identity before Prowler can scan it.
- Moving a subscription between management groups can change the permissions it inherits and may require a connection test or rediscovery.
See [Azure Subscription Scope](/user-guide/providers/azure/subscriptions) for subscription selection and CLI options.
## Limitations
- Management groups do not replace subscription providers in Prowler.
- Azure role inheritance depends on the management-group hierarchy and the scope of each assignment; verify access on every subscription selected for scanning.
- The Prowler Cloud workflow is designed around Azure management-group discovery and subscription onboarding. The Prowler CLI workflow still requires explicit subscription selection when restricting scans.
- Changes to management-group membership or role assignments may not be reflected until the hierarchy is refreshed and access is tested again.
@@ -25,14 +25,4 @@ Check the [Authentication > Subscription Scope Permissions](/user-guide/provider
## Recommendation for Managing Multiple Subscriptions
Scanning multiple subscriptions requires creating and assigning roles for each, which can be a time-consuming process. To streamline subscription management and auditing, use management groups in Azure. This approach allows Prowler to efficiently organize and audit multiple subscriptions collectively.
1. **Create a Management Group**: Follow the [official guide](https://learn.microsoft.com/en-us/azure/governance/management-groups/create-management-group-portal) to create a new management group.
![Create management group](/images/create-management-group.gif)
2. **Assign Roles**: Assign necessary roles to the management group, similar to the [role assignment process](#assigning-permissions-for-subscription-scans).
Role assignment should be done at the management group level instead of per subscription.
3. **Add Subscriptions**: Add all subscriptions you want to audit to the newly created management group. ![Add Subscription to Management Group](/images/add-sub-to-management-group.gif)
Scanning multiple subscriptions requires creating and assigning roles for each, which can be a time-consuming process. To streamline subscription management and auditing, use [Azure Management Groups](/user-guide/providers/azure/management-groups) to organize subscriptions and assign permissions collectively.
@@ -4,6 +4,8 @@ title: 'Scanning a Specific GCP Organization'
By default, Prowler scans all Google Cloud projects accessible to the authenticated user.
For the cross-provider organization lifecycle and capability comparison, see [Organizations Across Cloud Providers](/user-guide/organizations).
To limit the scan to projects within a specific Google Cloud organization, use the `--organization-id` option with the GCP organization’s ID:
```console
@@ -42,7 +42,7 @@ Required for scanning repository security settings:
| Permission | Access Level | Purpose | Checks Enabled |
|------------|-------------|---------|----------------|
| **Administration** | Read | Branch protection, security settings | All branch protection checks, secret scanning status |
| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, secret scanning status, `repository_default_workflow_permissions_read_only` |
| **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` |
| **Metadata** | Read | Basic repository information | All checks (automatically granted) |
| **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` |
@@ -63,7 +63,7 @@ Required for scanning organization-level security settings:
| Permission | Access Level | Purpose | Checks Enabled |
|------------|-------------|---------|----------------|
| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` |
| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` |
| **Members** | Read | Member access reviews | Organization membership auditing |
#### Account Permissions (Fine-Grained PAT only)
@@ -83,8 +83,8 @@ With the **Read-only permissions** listed above, Prowler can run:
| Check Category | Coverage | Notes |
|----------------|----------|-------|
| Branch protection checks (12 checks) | ✅ Full | Signed commits, status checks, PR reviews, etc. |
| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS |
| Organization checks (3 checks) | ✅ Full | MFA, repo creation policies, default permissions |
| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS, default workflow permissions |
| Organization checks (5 checks) | ✅ Full | MFA, repo creation policies, default permissions, Actions workflow permissions |
| Compliance frameworks | ✅ Full | CIS GitHub Benchmark and others |
| Merge settings (`delete_branch_on_merge`) | ⚠️ MANUAL | Requires write permission (see below) |
@@ -171,6 +171,7 @@ Use OAuth App Tokens when building applications that need delegated user permiss
- `repo`: Full control of repositories
- `read:org`: Read organization and team membership
- `admin:org`: Required by `organization_default_workflow_permissions_read_only` and `organization_actions_pull_request_approval_disabled` to read the organization Actions workflow permissions
- `read:user`: Read user profile data
**Create an OAuth App:**
@@ -214,7 +215,7 @@ If a GitHub App is required:
| Permission | Access Level | Purpose | Checks Enabled |
|------------|-------------|---------|----------------|
| **Administration** | Read | Branch protection, security settings | All branch protection checks, `repository_secret_scanning_enabled` |
| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, `repository_secret_scanning_enabled`, `repository_default_workflow_permissions_read_only` |
| **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` |
| **Metadata** | Read | Basic repository information | All checks (automatically granted) |
| **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` |
@@ -223,7 +224,7 @@ If a GitHub App is required:
| Permission | Access Level | Purpose | Checks Enabled |
|------------|-------------|---------|----------------|
| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` |
| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` |
| **Members** | Read | Member access reviews | Organization membership auditing |
**Create a GitHub App:**
@@ -34,4 +34,4 @@ prowler scaleway
## Required Scaleway Permissions
The API key bearer needs read access to the IAM API in order to list users and API keys. The `IAMReadOnly` policy is sufficient. Refer to the [Scaleway IAM policy reference](https://www.scaleway.com/en/docs/identity-and-access-management/iam/reference-content/permission-sets/) for the full list of permissions.
The API key bearer needs read access to the IAM API to list users and API keys. The `IAMReadOnly` policy is sufficient. Refer to the [Scaleway IAM policy reference](https://www.scaleway.com/en/docs/identity-and-access-management/iam/reference-content/permission-sets/) for the full list of permissions.
@@ -13,7 +13,7 @@ StackIT support in Prowler is community-maintained. For commercial support or to
Before running Prowler with the StackIT provider, ensure you have:
1. A StackIT account with at least one project
2. A StackIT service account key file with permissions on the project (`iaas.viewer` is enough for the currently shipped IaaS checks; `project.owner` works for any future service). See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup.
2. A StackIT service account key file with project permissions for the services to scan. `iaas.viewer` is sufficient for the currently shipped IaaS checks. For SKE, assign the project-scoped `Reader` role as a broad read-only convenience, or create a custom role with `resource-manager.project.get`, `ske.cluster.list`, `ske.cluster.get`, and `ske.version.list`. The `resource-manager.project.get` permission is optional for scanning; it only lets Prowler verify and display the project name. See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup.
3. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli))
## Prowler CLI
@@ -124,7 +124,9 @@ Prowler outputs findings to the console and writes reports to the `output/` dire
| Service | StackIT API | Description | Example Checks |
|---------|-------------|-------------|----------------|
| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted` |
| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted`, `iaas_server_public_ip_attached` |
| **Object Storage** | `objectstorage` | Buckets, object lock, retention policies, access keys | `objectstorage_bucket_object_lock_enabled`, `objectstorage_bucket_retention_policy`, `objectstorage_access_key_expiration` |
| **Kubernetes Engine** | `ske` | Managed Kubernetes clusters | `ske_cluster_no_public_endpoint` |
Additional services will be added in future releases. Track progress in the [Prowler release notes](https://github.com/prowler-cloud/prowler/releases).
@@ -136,6 +138,8 @@ If the scan fails with a 401 error, the service account key is no longer valid (
### Permission Errors
If checks fail with a 403 error, the service account is missing the required role on the project. Re-check the role assignment in the StackIT portal (`iaas.viewer` is the minimum for the shipped IaaS checks).
If the connection probe warns that Resource Manager access could not be verified with a 403 response, Prowler continues to service discovery. Grant the optional `resource-manager.project.get` permission to verify and display the project name.
If a service call fails with a 403 response, the service account is missing a permission required by that service. `iaas.viewer` is sufficient for the shipped IaaS checks. The `ske_cluster_no_public_endpoint` check lists clusters and therefore requires `ske.cluster.list`; STACKIT least-privilege custom-role examples also include `ske.cluster.get` and `ske.version.list`. Service API calls remain authoritative and stop the scan when access is denied.
For detailed setup steps, see the [Authentication guide](/user-guide/providers/stackit/authentication).
@@ -1,42 +0,0 @@
---
title: "Supabase Authentication in Prowler"
---
Prowler for Supabase authenticates to the hosted Supabase Management API using a **Personal Access Token** (PAT). Prowler reads the token exclusively from `SUPABASE_ACCESS_TOKEN`; there is no credential CLI argument, and Prowler does not read Supabase CLI state files.
## Personal Access Token Security
Supabase PATs inherit the privileges of the user account that created them. Use a dedicated least-privilege or read-only organization account where available, set a suitable token expiry, store the token in a secret manager, and rotate it regularly.
<Warning>
A PAT is not independently scoped to read-only Management API access. Compromise of the token grants the same Management API privileges as its issuing user account.
</Warning>
## Required Access
The issuing account must be able to call:
- [`GET /v1/organizations`](https://supabase.com/docs/reference/api/v1-list-all-organizations)
- [`GET /v1/organizations/{slug}/members`](https://supabase.com/docs/reference/api/v1-list-organization-members)
An invalid token returns `401`, insufficient organization permissions return `403`, and rate limiting returns `429`. Prowler treats all three as scan errors rather than compliant results. Supabase applies a standard limit of approximately 120 Management API requests per minute for each user and scope; Prowler uses the response rate-limit reset headers before retrying.
## Configure Authentication
Create a PAT from [Supabase Account Tokens](https://supabase.com/dashboard/account/tokens), then export it:
```bash
export SUPABASE_ACCESS_TOKEN="your-personal-access-token"
prowler supabase
```
For CI/CD, configure `SUPABASE_ACCESS_TOKEN` as a masked secret environment variable. Do not place it in command arguments, repository files, or job logs.
## Out of Scope
This proof of concept does not support:
- OAuth 2.0 Authorization Code with Proof Key for Code Exchange (PKCE)
- Direct PostgreSQL authentication
- Self-hosted Supabase deployments
- Supabase CLI credential or state files
@@ -1,40 +0,0 @@
---
title: "Getting Started With Supabase on Prowler"
---
Prowler for Supabase scans hosted Supabase Cloud organizations through the Management API. This proof of concept checks whether each organization member has multi-factor authentication (MFA) enabled.
<Warning>
This proof of concept supports Prowler SDK and Prowler CLI only. It does not support onboarding through Prowler Cloud, Prowler Private Cloud, or Prowler Local Server; self-hosted Supabase; direct PostgreSQL access; or OAuth.
</Warning>
## Prerequisites
1. A hosted Supabase Cloud account with access to at least one organization.
2. A Supabase Personal Access Token (PAT) for an account that can list organizations and organization members.
3. A dedicated least-privilege or read-only organization account where your Supabase plan and organization roles allow one.
See [Supabase Authentication in Prowler](./authentication) for PAT setup and security guidance.
## Run a Scan
Set the PAT through the environment and run Prowler:
```bash
export SUPABASE_ACCESS_TOKEN="your-personal-access-token"
prowler supabase
```
To run only the organization member MFA check:
```bash
prowler supabase --check organizations_member_mfa_enabled
```
## Checks Shipped
| Check ID | Severity | Description |
|---|---|---|
| `organizations_member_mfa_enabled` | High | Creates one finding per organization member and fails members whose Supabase account does not have MFA enabled. |
The finding resource UID is the Management API `user_id`. Prowler does not include member email addresses in findings or normal logs.
@@ -4,6 +4,8 @@ title: 'AWS Organizations Bulk Provisioning in Prowler'
Prowler offers an automated tool to discover and provision all AWS accounts within an AWS Organization. This streamlines onboarding for organizations managing multiple AWS accounts by automatically generating the configuration needed for bulk provisioning.
For the cross-provider organization lifecycle and terminology, see [Organizations Across Cloud Providers](/user-guide/organizations).
The tool, `aws_org_generator.py`‎, complements the [Bulk Provider Provisioning](./bulk-provider-provisioning) tool and is available in the Prowler repository at: [util/prowler-bulk-provisioning](https://github.com/prowler-cloud/prowler/tree/master/util/prowler-bulk-provisioning)
<Note>
+46 -6
View File
@@ -1,7 +1,7 @@
---
title: 'Alerts'
sidebarTitle: 'Alerts'
description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.'
description: 'Create alerts from Prowler Cloud findings, deliver them to email recipients and Slack channels, and monitor relevant security changes after scans or in daily digests.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
@@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.26.0" />
Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
Alerts notify their destinations — email recipients, Slack channels, or both — when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
<SubscriptionBanner />
@@ -19,12 +19,13 @@ Before creating Alerts, ensure that:
* At least one scan has completed and produced findings.
* The user role includes the `manage_alerts` permission.
* To deliver Alerts to Slack channels, a Slack workspace is connected, at least one channel is authorized on it, and the integration's connection check has confirmed that channel. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration).
The `manage_alerts` permission is required to create, edit, test, enable, disable, and delete Alerts. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details.
## How Alerts Work
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and sends an email digest when matching findings exist.
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and notifies the Alert's destinations when matching findings exist: an email digest to each recipient, a message to each Slack channel, or both. Destination kinds are independent — neither requires the other, and neither displaces the other.
<Note>
Alerts evaluate findings with status `FAIL` only. Findings with status `PASS` or `MANUAL`, and muted findings, never trigger an Alert regardless of the saved filters.
@@ -53,6 +54,7 @@ To create an Alert:
* **Description:** Add optional context for the Alert.
* **Frequency:** Select when Prowler Cloud should evaluate the Alert.
* **Recipients:** Select the recipients who should receive the email digest.
* **Destination channels:** Select the Slack channels that should receive the Alert. See [Slack Channel Destinations](#slack-channel-destinations).
![Create Alert Modal](/images/prowler-app/alerts/create-alert-modal.png)
@@ -86,11 +88,18 @@ Navigate to **Alerts** to review and manage existing Alerts.
![Alerts List](/images/prowler-app/alerts/alerts-list.png)
The **Destinations** column summarizes where each Alert delivers, without the Alert being opened:
* **Email recipients:** The first address, plus a count of the rest, such as `security@example.com +2 more`.
* **Slack channels:** The first channel, plus a count of the rest, such as `#sec-alerts +1 more`.
Each summary is omitted when that destination kind is empty, and the column reads **No destinations** when an Alert has neither.
Each Alert provides these actions:
| Action | Description |
|--------|-------------|
| Edit | Update name, description, recipients, frequency, or filters. |
| Edit | Update name, description, recipients, Slack channels, frequency, or filters. |
| Enable/Disable | Start or stop Alert evaluation without deleting the Alert. |
| Delete | Permanently remove the Alert. |
@@ -125,6 +134,37 @@ By default, the **organization owner** receives a **daily digest** for **critica
If a recipient unsubscribes from Alerts, that address stops receiving digests until it is reconfirmed.
An Alert does not require email recipients: an Alert that targets Slack channels only is accepted with those channels as its sole destinations. An Alert with no destinations at all stays valid and keeps evaluating its filters, but it delivers nothing.
## Slack Channel Destinations
<VersionBadge version="5.40.0" />
An Alert can post to Slack channels alongside its email recipients, or instead of them. The **Destination channels** field sits directly below **Recipients** in the Alert form, both when creating an Alert and when editing one. When the Alert matches findings, Prowler Cloud posts a message to each of its channels and sends the email digest to each of its recipients, independently of each other.
The channels offered are the confirmed channels of the connected Slack integration, never the whole Slack workspace. Widening the pool takes two steps on the integration: authorize the channel there, then run its connection check, which confirms the channel by posting a one-time confirmation message to it. Once confirmed, the channel is selectable on every Alert. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration) for connecting a workspace, authorizing its channels, and confirming them.
A channel that was authorized a moment ago but does not appear in the Alert form has not been confirmed yet. Run **Test connection** on the Slack integration, then reopen the Alert form.
Private channels are identified as **Private** both in the open channel list and on the selected channels once the list is closed, so a private destination is never mistaken for a public one.
### When Slack Channels Cannot Be Selected
The field is always present, so channel delivery is never silently missing. It reports why it cannot be used:
| State | What the Alert form shows |
|-------|---------------------------|
| No Slack workspace connected | The field is visible but cannot be edited, explaining that posting Alerts to Slack channels needs a connected Slack workspace, with a link to the Slack integration. |
| Workspace connected, no confirmed channels | A notice that no channels are available yet and that they are authorized and confirmed on the Slack integration, with the same link. |
In both states the rest of the Alert is unaffected: it can still be created or saved with its filters, frequency, and email recipients.
<Note>
Slack destinations stay in step with the integration. Removing a channel from the integration's authorized set — or disconnecting the Slack integration altogether — removes that channel from every Alert that targeted it, so an Alert never keeps a destination Prowler can no longer deliver to. The Alert keeps its filters, frequency, and email recipients, and future delivery to that channel stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Restoring delivery means authorizing and confirming the channel again on the integration, then selecting it again on the Alert.
</Note>
Saving an Alert that names a channel which is not a confirmed channel of a connected Slack integration is refused, and the reason is reported on the Alert form. Authorize and confirm the channel on the Slack integration, or remove it from the Alert, and save again.
## Email Notifications
When an Alert matches findings, Prowler Cloud sends a security alert email that summarizes the matching findings. The email includes:
@@ -141,6 +181,6 @@ When an Alert matches findings, Prowler Cloud sends a security alert email that
* **Start with focused filters:** Create Alerts for specific high-priority scopes, such as critical findings, production providers, or important services.
* **Use clear names:** Choose names that explain the intent of the Alert.
* **Review recipients regularly:** Keep recipient lists aligned with current ownership.
* **Review destinations regularly:** Keep recipient lists and channel selections aligned with current ownership.
* **Test before saving edits:** Use **Test** after changing filters to confirm that the Alert matches the expected findings.
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or recipients.
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or destinations.
@@ -69,7 +69,7 @@ To remove **another** user from your organization, use the [_Expel from organiza
#### Inviting Users
<Note>
Please be aware that at this time, an email address can only be associated with a single Prowler account.
Note that an email address can only be associated with a single Prowler account.
</Note>
Follow these steps to invite a user to your account:
@@ -0,0 +1,195 @@
---
title: "Slack Integration"
sidebarTitle: 'Slack'
description: 'Connect a Slack workspace to Prowler Cloud or Prowler Private Cloud, authorize the channels Prowler posts to, and verify the connection.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.40.0" />
<SubscriptionBanner />
Prowler Cloud and Prowler Private Cloud connect to a Slack workspace so security updates arrive where teams already work. Connecting takes one approval in Slack — there is no bot token to create, copy, or store by hand — and Prowler records the set of channels it is authorized to post to.
Integrating Prowler Cloud or Prowler Private Cloud with Slack provides:
* **Approval-based setup:** Approve Prowler once in Slack instead of building a Slack app and pasting a token.
* **Confirmed destinations:** The connection check verifies every authorized channel and confirms each new one in the channel itself, so a channel Prowler cannot reach is reported before anything depends on it.
* **Controlled reach:** Prowler posts only to the channels authorized on the integration, and private channels stay invisible until the Prowler app is invited to them.
<Note>
This guide covers the Slack integration in Prowler Cloud and Prowler Private Cloud. It is unrelated to the Prowler CLI `--slack` flag, which posts a scan summary from the command line using a self-created Slack app and the `SLACK_API_TOKEN` and `SLACK_CHANNEL_NAME` environment variables — see [CLI Integrations](/user-guide/cli/tutorials/integrations) for that feature.
</Note>
## How the Slack Integration Works
When connected and configured:
1. A Slack workspace is approved once through Slack's app install flow, and Prowler stores the resulting credential encrypted.
2. Prowler reads the channels it can post to: the workspace's public channels, plus the private channels the Prowler app has been invited to.
3. Several of those channels are selected and saved as the integration's authorized channels.
4. The connection check verifies the credential and every authorized channel, and posts a one-time confirmation message to each channel it has not confirmed yet.
5. Features that deliver to Slack, such as [Alerts](/user-guide/tutorials/prowler-alerts), choose their destinations from the confirmed channels.
6. Disconnecting removes the integration from Prowler and attempts to revoke Prowler's access at Slack.
## Prerequisites
The Slack integration is available only in **Prowler Cloud** and **Prowler Private Cloud**. Prowler Local Server does not serve the Slack endpoints at all, so the Slack card does not appear on the Integrations page and the management page redirects away.
Configuring and using the Slack integration requires the **Manage Integrations** permission. The integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group.
One Slack workspace connects per tenant. Approving Prowler again in the same workspace refreshes the stored credential and keeps the authorized channels, but it resets their confirmations and the connection state — the connection check has to be run again. Approving Prowler in a *different* workspace is refused until the current workspace is disconnected: a workspace is never swapped out silently.
## Permissions Prowler Requests in Slack
Slack shows a consent screen listing everything the Prowler app asks for. Prowler requests exactly four bot scopes:
| Scope | Why Prowler Requests It |
|-------|-------------------------|
| `chat:write` | Post the confirmation message, and any later notification, to the authorized channels. |
| `chat:write.public` | Post to a public channel without first inviting the Prowler app to it. |
| `channels:read` | List public channels for the channel selection and resolve the chosen ones. |
| `groups:read` | List the private channels the Prowler app has been invited to, so they appear in the channel selection. |
Two of these read more broadly than they behave, and both are worth understanding before approving the app.
### What `chat:write.public` Does Not Grant
On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channels authorized on the integration.** The scope exists so that authorizing a public channel does not also require someone to invite the Prowler app to it first.
### Why a Private Channel Is Missing From the Channel List
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler` to it in Slack:
```text
/invite @Prowler
```
That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list.
## Connecting a Slack Workspace
To connect a Slack workspace to Prowler Cloud or Prowler Private Cloud:
1. In either product, navigate to **Integrations**.
2. Locate the **Slack** card and click **Manage**.
![Slack card on the Integrations page in Prowler Cloud or Prowler Private Cloud](/images/prowler-app/slack/integrations-tab.png)
3. Click **Add to Slack**.
![Slack management page before a workspace is connected, showing the Add to Slack action](/images/prowler-app/slack/no-workspace-connected.png)
4. In Slack, select the workspace to connect and approve the permissions listed on the consent screen.
5. Slack returns to Prowler Cloud or Prowler Private Cloud, which completes the install and shows the connected workspace.
![Connected Slack workspace with no channels authorized yet](/images/prowler-app/slack/connected-workspace.png)
The connected card reports the workspace name and a **Not checked yet** status: the connection is checked against the authorized channels, and none are authorized at this point. Authorizing them is the next step. Once at least one channel is authorized, **Test connection** verifies the credential and every authorized channel, and confirms the ones not confirmed yet.
<Note>
Declining the consent screen creates nothing. Prowler reports that the workspace was not connected and offers to start again.
</Note>
## Authorizing Destination Channels
Prowler posts to the channels authorized on the integration. Several channels can be authorized at once, and once the connection check has confirmed them they are the pool every consumer of the integration draws from: an [Alert](/user-guide/tutorials/prowler-alerts) picks its Slack destinations from the confirmed channels, never from the whole workspace.
1. Open the **Destination channels** selection. It lists the workspace's public channels, plus the private channels the Prowler app has been invited to, each marked **Private**.
![Destination channels selection listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png)
2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance.
3. Click **Save channels**.
Prowler validates the selection against Slack and derives each channel name itself, so a recorded name can never drift from the channel it belongs to. Once the set is saved, the page reports where Prowler posts and runs the connection check over it.
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**.
A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out.
Saving a new selection replaces the authorized set: channels left out of it stop being authorized, and channels added to it are authorized but not yet confirmed. Changing which channels are in the set also resets the integration's connection state, so the check runs again over the new set — reordering the same channels does not. Saving an empty selection leaves the integration with no authorized channels, and **Test connection** cannot be run again until at least one channel is authorized.
<Warning>
Removing a channel from the authorized set also removes it from every Alert that targeted it. Those Alerts keep their filters, frequency, and email recipients, and future delivery to that channel simply stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Disconnecting the integration has the same effect on every channel it had authorized. Restoring delivery means authorizing and confirming the channel again here, then selecting it again on each Alert.
</Warning>
### Confirming the Authorized Channels
A channel becomes usable as a destination once the connection check has confirmed it. Click **Test connection**: it verifies the stored credential and every authorized channel, and posts a one-time message to each channel it has not confirmed yet.
```text
✅ Prowler connection verified. Notifications will be delivered to this channel.
```
Later checks never post that message again to a channel that is already confirmed, so it arrives once per channel. The integration reports as connected only when every check and every required confirmation succeeded; a failure names the channel that failed. The check needs at least one authorized channel — with none authorized, it cannot be run yet.
Confirmation is what makes a channel selectable elsewhere in Prowler Cloud. A channel authorized a moment ago is missing from an Alert's channel list until a connection check confirms it.
## Disconnecting a Slack Workspace
Disconnecting removes the integration from Prowler **and** attempts to revoke Prowler's access at Slack.
1. On the Slack management page, click **Disconnect**.
2. Review the confirmation, then click **Disconnect workspace**.
![Disconnect Slack workspace confirmation dialog](/images/prowler-app/slack/disconnect-confirmation.png)
The page returns to its unconnected state, ready for a new install.
### What Revocation Means
Revocation is attempted at Slack, and it is best-effort:
* **Revocation succeeded:** The stored credential no longer grants Prowler anything, and the integration is gone from Prowler.
* **Revocation failed:** The integration and the stored credential are gone from Prowler either way, so there is nothing to retry. Slack did not confirm the revocation, which means the Prowler app may still be installed in the workspace. Remove it from that workspace's Slack app settings.
* **Revocation unreported:** Slack's answer carried no outcome either way. The integration is gone from Prowler, and the disconnect is reported without any claim about revocation. When certainty matters, check the workspace's Slack app settings and remove the Prowler app if it is still installed.
Prowler reports the outcome it received: a failed revocation always names the manual cleanup step, and an unreported one is never presented as revoked.
<Warning>
Disconnecting cannot be undone, and it removes the Slack channels from every Alert that targeted them. Reconnecting means approving Prowler in Slack again, authorizing the destination channels again, confirming them with a connection check, and selecting them again on each Alert that posts to Slack.
</Warning>
## Integration Status
The Slack management page reports the state of the connection and offers these actions:
| Button | Purpose | Notes |
|--------|---------|-------|
| **Test connection** | Verify the credential and every authorized channel, and confirm the ones not confirmed yet | Posts the confirmation message once per channel and updates the last-checked time. Cannot be run until at least one channel is authorized |
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
| **Save channels** | Record the selected channels as the integration's authorized set | Enabled once the selection differs from the authorized set |
| **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting |
## Troubleshooting
### Slack Is Not Available in This Environment Yet
The Prowler Slack app is not configured for the deployment being used, so no workspace can be connected. This resolves without any action on the tenant's side — the page starts working as soon as the app is configured.
### A Private Channel Does Not Appear in the Channel List
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
### Connection Test Fails
* Confirm every authorized channel still exists and has not been archived. A failure names the channel Slack refused, and the integration reports as connected only when every authorized channel passes.
* For a private authorized channel, confirm the Prowler app is still a member of it.
* Confirm the Prowler app is still installed in the workspace.
### A Channel Is Missing From an Alert's Channel List
The channel is authorized here but not confirmed yet. Click **Test connection**: it confirms every authorized channel it has not confirmed, and confirmed channels become selectable on Alerts.
### Prowler's Access Has Been Revoked
When Slack stops accepting the stored credential — because a workspace administrator revoked it, or the app was removed from the workspace — Prowler reports the workspace as disconnected and offers **Reconnect to Slack**. Approving Prowler in Slack again restores access.
### The Connection Check Fails on a Channel
* Check the outcome reported on the page: it names the channel Slack refused and the reason Slack gave — an archived or deleted channel surfaces here rather than failing silently.
* Confirm that channel is still one of the intended destinations, and that it has not been archived or deleted in Slack.
* For a private channel, confirm the Prowler app is still a member of it.
* One unreachable channel is enough to report the integration as not connected, so removing a retired channel from the authorized set clears the failure — bearing in mind that removing it also removes it from every Alert that targeted it.
@@ -10,6 +10,8 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
Prowler Cloud onboards every AWS account in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI.
For the cross-provider organization lifecycle and terminology, see [Organizations Across Cloud Providers](/user-guide/organizations).
<SubscriptionBanner>
For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations).
</SubscriptionBanner>
@@ -266,7 +268,7 @@ Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the s
After launching:
- Scans appear in the **Scans** page as they start and complete.
- Results populate the **Overview** and **Findings** pages.
- Prowler runs an **automatic sync every 6 hours** to detect accounts added to or removed from your Organization. New accounts under the targeted OU or root are onboarded automatically.
- To detect accounts added to or removed from the AWS Organization, repeat the discovery flow described in [Add or Remove Organization Accounts](#add-or-remove-organization-accounts).
## Manage Your Organization After Onboarding
@@ -288,6 +290,24 @@ Open the row actions menu on the organization row on the **Providers** page.
Organizational unit rows carry the same **Test Connections** and **Delete Organizational Unit** actions, scoped to the accounts beneath them.
### Add or Remove Organization Accounts
To refresh the account membership of an existing AWS Organization, repeat the same discovery flow used during onboarding:
1. Navigate to **Providers**, click **Add Provider**, and select **Amazon Web Services**.
2. Choose **Add Multiple Accounts With AWS Organizations**.
3. Enter the existing **Organization ID**, proceed to **Authentication Details**, and use the existing deployment account **Role ARN**.
4. Confirm that the stack is deployed and click **Authenticate**. Prowler reuses the existing organization and starts a new discovery instead of creating a duplicate.
The refreshed tree shows the accounts currently returned by AWS Organizations:
- **New accounts** appear in the tree. Select them, test their connections, and save the configuration to connect them as providers. Existing providers and their historical data are preserved.
- **Accounts that left the Organization** no longer appear in the tree. Discovery does not automatically delete their existing providers. To remove one, return to the **Providers** page, open the account provider actions, and select **Delete Provider**.
<Danger>
Deleting a provider permanently removes its scans, findings, resources, and other stored data. Confirm that the account has left the AWS Organization and that its historical data is no longer required before deleting it.
</Danger>
### Update Organization Credentials
Choosing **Update Credentials** re-enters the Authentication Details step. Because the organization already holds a credential, Prowler warns before overwriting it and names how many providers re-authenticate with the new one:
@@ -458,16 +478,17 @@ Deploy the ProwlerScan role to every member account with a [CloudFormation Stack
</Note>
1. In your management account, navigate to **CloudFormation > StackSets > Create StackSet** ([open directly](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)).
2. Choose **Service-managed permissions** so AWS Organizations deploys the role automatically across current and future member accounts.
3. Select **Amazon S3 URL** as the template source and paste:
2. Choose **Service-managed permissions**.
3. Enable **Automatic deployment** so CloudFormation deploys the role to accounts added to the targeted root or OUs. Configure the account removal behavior based on whether the stack and its resources should be retained when an account leaves the target.
4. Select **Amazon S3 URL** as the template source and paste:
```
https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml
```
4. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard.
5. Choose your deployment targets (entire organization or specific OUs) and regions, then click **Create StackSet**.
6. Open the **Stack instances** tab and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Deployment typically takes **2–5 minutes**; large organizations (500+ accounts) may take longer.
5. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard.
6. Choose your deployment targets (entire organization or specific OUs) and regions, then click **Create StackSet**.
7. Open the **Stack instances** tab and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Deployment typically takes **2–5 minutes**; large organizations (500+ accounts) may take longer.
The StackSet role uses read-only access only (`SecurityAudit`, `ViewOnlyAccess`, plus a small set of additional read-only permissions). Prowler makes no changes to your accounts. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. When you add new accounts under the targeted OU or root, the StackSet deploys the role automatically, and Prowler's 6-hour sync onboards them end-to-end.
The StackSet role uses read-only access only (`SecurityAudit`, `ViewOnlyAccess`, plus a small set of additional read-only permissions). Prowler makes no changes to your accounts. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. When **Automatic deployment** is enabled, the StackSet deploys the role to new accounts under the targeted OU or root. Repeat the [organization discovery flow](#add-or-remove-organization-accounts) to connect those accounts in Prowler Cloud.
## Key Concepts
@@ -153,6 +153,18 @@ export PROWLER_CLOUD_API_KEY="pk_your_api_key_here"
prowler aws --push-to-cloud
```
### TLS Certificate Trust
For `--push-to-cloud` uploads, Prowler CLI creates one ingestion-scoped TLS context and validates HTTPS certificates with one handshake and one POST request. The upload does not retry or fall back to another TLS configuration. Redirect responses are rejected.
The ingestion context combines the operating system roots with the default certificate authority (CA) roots bundled with Requests. If `REQUESTS_CA_BUNDLE` is configured, Prowler CLI also loads that file or directory into the ingestion context. Otherwise, Prowler CLI loads `CURL_CA_BUNDLE` when configured.
`REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` can also affect other Requests-based connections throughout the Prowler CLI process, including provider authentication. A bundle containing only a private CA can cause connections to public services to fail before the upload starts. Installing the private CA in the operating system or container trust store is recommended. If a custom bundle is required, it must include both the public CA roots, such as the certifi bundle, and the required private CA certificates.
For Prowler Private Cloud deployments that use an organization CA or a TLS-intercepting corporate proxy, installing the required root CA in the operating system or container store remains the recommended approach. Containers have an isolated system CA store, so add the organization or proxy CA to the container image or runtime, then run the operating system's CA update command, such as `update-ca-certificates`, before starting Prowler CLI. Installing a CA on the container host does not automatically install it inside the container.
Prowler CLI does not create, modify, or remove these environment variables. The custom TLS context created by `--push-to-cloud` applies only to the temporary ingestion session and does not change API, provider, integration, global SSL, or unrelated Requests session behavior.
### Combining with Output Formats
When using `--push-to-cloud` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload:
+2 -1
View File
@@ -113,7 +113,8 @@ make test-mcp # Run the MCP test suite exactly as CI does
- [ ] Models use `MinimalSerializerMixin`
- [ ] API responses transformed to simplified models
- [ ] No hardcoded secrets
- [ ] Error handling returns structured responses
- [ ] Failures are raised, not returned (see `prowler_mcp_server/lib/errors.py`);
a returned error dict is reported to the client as a success
- [ ] Parameter descriptions use Pydantic `Field()`
- [ ] Tests added under `mcp_server/tests/`, mirroring the source path below the
package root (`prowler_mcp_server/prowler_app/tools/` -> `tests/prowler_app/tools/`),
+16
View File
@@ -29,6 +29,22 @@ FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212
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)
# 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
# vulnerable versions. libcrypto3 and libssl3 are both built from openssl and
# are flagged separately, so both are named.
# `>=` rather than `=`: Alpine keeps only the newest build of a package in a
# branch's index, so an exact pin breaks this build the day one of these is
# superseded. Drop an entry once the base image ships that version or later.
RUN apk add --no-cache --upgrade \
"sqlite-libs>=3.53.4-r0" \
"libcrypto3>=3.5.8-r0" \
"libssl3>=3.5.8-r0"
# Create non-root user for security
# Using specific UID/GID for consistency across environments
RUN addgroup -g 1001 prowler && \
@@ -0,0 +1 @@
`prowler_docs_search` returns results again: it calls the search endpoint docs.prowler.com moved to, since the one it used no longer exists, and each result now names the page's title, the section it matched and a URL anchored at that section
@@ -0,0 +1 @@
`libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the container image, patching CVE-2026-14456
@@ -0,0 +1 @@
`sqlite-libs` upgraded to 3.53.4-r0 in the container image, patching CVE-2026-11822 and CVE-2026-11824
@@ -0,0 +1 @@
Failures shared by every tool - a rejected credential, a missing permission, a rate limit, an outage, an unreachable API, a bad argument - are now explained with a message that says what went wrong and what to do about it
@@ -0,0 +1 @@
Stop relaying upstream response bodies to agents: a failed request now reaches the caller as a sentence this server wrote, with the full body kept to the logs, so a gateway error page or a debug traceback can no longer be replayed into a model's context
+238
View File
@@ -0,0 +1,238 @@
"""Shared failure classification for every tool in this server."""
import json
from typing import Any
import httpx
from fastmcp.exceptions import McpError, NotFoundError, ToolError
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from pydantic import ValidationError
from prowler_mcp_server.lib.logger import logger
# ------------------------------------------------------------- failure types
class ProwlerAPIError(Exception):
"""An error response returned by the Prowler API.
Attributes:
status_code: HTTP status the API answered with
detail: JSON:API `errors[0].detail`, None when there is none to trust
"""
def __init__(
self, message: str, status_code: int, *, detail: str | None = None
) -> None:
super().__init__(message)
self.status_code: int = status_code
# Prowler's own JSON:API `errors[0].detail`, which our API writes for a
# caller and we therefore trust. None when the body was not JSON:API --
# a gateway HTML page or a debug traceback, which is exactly the case
# that must never be repeated to a model -- and None for a 5xx, see
# `jsonapi_detail`.
self.detail: str | None = detail
class ProwlerAPIUnreachable(Exception):
"""The request never got an answer, so whether it was applied is unknown."""
class ProwlerAPIInvalidResponse(Exception):
"""The API answered, but with a body this server could not read as JSON."""
def jsonapi_detail(response: httpx.Response) -> str | None:
"""Return the API's own JSON:API error detail, when there is one to trust.
Args:
response: Error response returned by the Prowler API
Returns:
`errors[0].detail`, or None if the status is 5xx or the body is not
JSON:API
"""
if response.status_code >= 500:
return None
try:
errors = response.json().get("errors")
except Exception:
return None
if not isinstance(errors, list) or not errors:
return None
detail = errors[0].get("detail") if isinstance(errors[0], dict) else None
return detail if isinstance(detail, str) and detail.strip() else None
class InvalidArgument(ValueError):
"""An argument this server rejected before any request went out."""
# ------------------------------------------------------------------- messages
def _describe_prowler_api_error(exc: ProwlerAPIError) -> str:
"""Describe a request the Prowler API answered with an error status."""
status = exc.status_code
# The fallback: a status this server has nothing specific to say about.
message = (
f"Prowler rejected the request with status {status} and gave no reason. "
"Check the arguments against the tool description."
)
if status == 401:
message = (
"Prowler rejected this server's credential: it is missing, malformed "
"or expired. In HTTP mode the request needs an 'Authorization: Bearer "
"<token>' header; in STDIO mode PROWLER_API_KEY must hold a valid key."
)
elif status == 403:
message = (
"The credential is valid but not allowed to do this. Use "
"prowler_get_current_user to see which role it holds."
)
elif status == 429:
message = (
"Prowler is rate limiting this credential. Wait before retrying, and "
"narrow the request with tighter filters or a smaller page_size."
)
elif status >= 500:
message = (
f"Prowler answered {status}: the request failed on Prowler's side, "
"not because of anything in the call."
)
elif exc.detail:
# Written by the Prowler API for a caller to read, so it is ours to relay.
message = f"Prowler rejected the request ({status}): {exc.detail}"
return message
def _describe_upstream_http_error(exc: httpx.HTTPError) -> str:
"""Describe a failure from an upstream this server reads directly."""
# `.request` raises rather than returning None when it was never set, and
# this runs inside an exception handler, so it is read defensively.
request = getattr(exc, "_request", None)
host = request.url.host if request is not None else "the upstream service"
if not isinstance(exc, httpx.HTTPStatusError):
return f"{host} could not be reached: {type(exc).__name__}."
status = exc.response.status_code
if status == 429:
return f"{host} is rate limiting this server. Wait before retrying."
if status >= 500:
return (
f"{host} answered {status}: the request failed on its side, not "
"because of anything in the call."
)
return (
f"{host} rejected the request with status {status}. Check the arguments "
"against the tool description."
)
def _describe_failure(exc: BaseException) -> str | None:
"""Describe a failure for a model to read, or return None to leave it masked."""
# InvalidArgument first: it is the only ValueError here whose message this
# server wrote. The ones below quote the input they rejected, so they are
# matched by type and answered with a message of our own.
if isinstance(exc, InvalidArgument):
return str(exc)
if isinstance(exc, ProwlerAPIError):
return _describe_prowler_api_error(exc)
if isinstance(exc, ProwlerAPIInvalidResponse):
return (
"Prowler answered with a body this server could not read, so the "
"outcome of the call is unknown. If it changes anything, check the "
"current state before sending it again."
)
if isinstance(exc, ProwlerAPIUnreachable):
# The only failure a model can turn into a duplicate write by repeating.
return (
f"Prowler could not be reached: {exc}. Whether the request was "
"applied is unknown, so check the current state before sending it again."
)
if isinstance(exc, ValidationError):
problems = [
f"{'.'.join(str(part) for part in error['loc']) or '(argument)'}: {error['msg']}"
for error in exc.errors(include_url=False)
]
# Field and expectation only: pydantic quotes the rejected value back.
return f"Invalid arguments -- {'; '.join(problems)}."
if isinstance(exc, json.JSONDecodeError):
return (
"An argument that had to be a JSON object could not be parsed. Send "
"it as a real object rather than as a quoted or escaped string."
)
# Only the Hub and documentation tools reach here: the Prowler API client
# converts its own httpx failures into the two types matched above.
if isinstance(exc, (httpx.HTTPStatusError, httpx.RequestError)):
return _describe_upstream_http_error(exc)
return None
# ----------------------------------------------------------------- middleware
class SharedFailureMiddleware(Middleware):
"""Replace the failures many tools share with a message a model can act on."""
async def on_call_tool(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> Any:
"""Replace a shared tool failure with the message that describes it.
Args:
context: Tool call being handled
call_next: Rest of the middleware chain
Returns:
The tool result when the call succeeded
Raises:
ToolError: With the classified message when the failure is one this
module recognises
"""
try:
return await call_next(context)
except (NotFoundError, McpError):
# Protocol-level, not a tool failure. Must stay exactly as it is.
raise
except Exception as exc:
# FastMCP wraps whatever the tool raised and records it as __cause__.
# An absent cause means the message is already the final word: a
# ToolError raised deliberately without a `from` clause.
original = exc.__cause__
message = _describe_failure(original) if original is not None else None
if message is None:
if original is not None:
logger.warning(
"Tool %s failed with an unclassified error: %s: %s",
getattr(context.message, "name", "<unknown>"),
type(original).__name__,
original,
)
raise
logger.warning(
"Tool %s failed: %s: %s",
getattr(context.message, "name", "<unknown>"),
type(original).__name__,
original,
)
raise ToolError(message) from exc
@@ -7,28 +7,22 @@ from typing import Any
from urllib.parse import urlparse
import httpx
from fastmcp.exceptions import ToolError
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.errors import (
InvalidArgument,
ProwlerAPIError,
ProwlerAPIInvalidResponse,
ProwlerAPIUnreachable,
jsonapi_detail,
)
from prowler_mcp_server.lib.logger import logger
from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
ALLOWED_EXTERNAL_DOMAINS: frozenset[str] = frozenset({"raw.githubusercontent.com"})
class ProwlerAPIError(Exception):
"""An error response returned by the Prowler API.
Raised only when the API answered with an error status, which tells a caller
something no plain exception can: the request reached Prowler and was
rejected, so it changed nothing. A timeout or a dropped connection stays a
bare exception because the request may well have been processed.
"""
def __init__(self, message: str, status_code: int) -> None:
super().__init__(message)
self.status_code: int = status_code
class HTTPMethod(StrEnum):
"""HTTP methods enum."""
@@ -88,7 +82,8 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
Raises:
ProwlerAPIError: If the API answered with an error status
Exception: If the request could not be completed
ProwlerAPIUnreachable: If the request got no answer
ProwlerAPIInvalidResponse: If the answer was not readable as JSON
"""
try:
token: str = await self.auth_manager.get_valid_token()
@@ -103,31 +98,61 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
json=json_data,
)
response.raise_for_status()
if not response.content:
return {
"success": True,
"status_code": response.status_code,
}
else:
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error during {method.value} {path}: {e}")
error_detail: str = ""
try:
error_data: dict[str, any] = e.response.json()
error_detail = error_data.get("errors", [{}])[0].get("detail", "")
except Exception:
error_detail = e.response.text
raise ProwlerAPIError(
f"API request failed: {e.response.status_code} - {error_detail}",
e.response.status_code,
status: int = e.response.status_code
# `jsonapi_detail` returns nothing for a 5xx, so a server error never
# puts upstream text into the exception message either.
detail: str | None = jsonapi_detail(e.response)
# The full body goes to the log and nowhere else. A body that is not
# JSON:API is upstream text of unknown provenance, and the exception
# message is read by a model.
logger.error(
"HTTP error during %s %s: %s %s",
method.value,
path,
status,
(e.response.text or "")[:500],
)
message = f"API request failed: {status}"
if detail:
message = f"{message} - {detail}"
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}")
raise ProwlerAPIUnreachable(
f"{method.value} {path} got no answer: {type(e).__name__}"
) from e
except Exception as e:
logger.error(f"Error during {method.value} {path}: {e}")
raise
if not response.content:
return {
"success": True,
"status_code": response.status_code,
}
# Parsed outside the block above so that a body we cannot read is told
# apart from an argument a tool could not parse: both are a
# `JSONDecodeError`, and only the second one is the caller's doing.
try:
return response.json()
except ValueError as e:
logger.error(
"Unreadable response body during %s %s: %s %s",
method.value,
path,
response.status_code,
(response.text or "")[:500],
)
raise ProwlerAPIInvalidResponse(
f"{method.value} {path} answered {response.status_code} with a "
"body that is not JSON"
) from e
async def get(
self, path: str, params: dict[str, any] | None = None
) -> dict[str, any]:
@@ -229,14 +254,14 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
Raw text content from the URL
Raises:
ValueError: If the URL domain is not in the allowlist
Exception: If the HTTP request fails
InvalidArgument: If the URL scheme or domain is not allowed
ToolError: If the fetch failed
"""
parsed = urlparse(url)
if parsed.scheme != "https":
raise ValueError(f"Only HTTPS URLs are allowed, got '{parsed.scheme}'")
raise InvalidArgument(f"Only HTTPS URLs are allowed, got '{parsed.scheme}'")
if parsed.hostname not in ALLOWED_EXTERNAL_DOMAINS:
raise ValueError(
raise InvalidArgument(
f"Domain '{parsed.hostname}' is not allowed. "
f"Allowed domains: {', '.join(sorted(ALLOWED_EXTERNAL_DOMAINS))}"
)
@@ -249,13 +274,20 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
response.raise_for_status()
return response.text
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error fetching external URL {url}: {e}")
raise Exception(
f"Failed to fetch external URL: {e.response.status_code}"
) from e
except Exception as e:
# The status is ours to report; the body is upstream text and stays
# in the log. No `from` clause: this sentence is the final word.
logger.error(
"HTTP error fetching external URL %s: %s %s",
url,
e.response.status_code,
(e.response.text or "")[:500],
)
raise ToolError(
f"Fetching {url} failed with status {e.response.status_code}."
)
except httpx.RequestError as e:
logger.error(f"Error fetching external URL {url}: {e}")
raise
raise ToolError(f"Fetching {url} got no answer: {type(e).__name__}.")
async def poll_task_until_complete(
self,
@@ -278,7 +310,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
The complete task response when terminal state is reached
Raises:
Exception: If task fails, is cancelled, or timeout is exceeded
ToolError: If the task fails, is cancelled, or the timeout is exceeded
"""
terminal_states = {"completed", "failed", "cancelled"}
start_time = asyncio.get_event_loop().time()
@@ -292,7 +324,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
# Check if we've exceeded the timeout
current_time = asyncio.get_event_loop().time()
if current_time >= max_time:
raise Exception(
raise ToolError(
f"Task {task_id} polling timed out after {timeout} seconds. "
f"The task may still be running. Try increasing the timeout or check task status manually."
)
@@ -311,10 +343,14 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
logger.info(f"Task {task_id} completed successfully")
return response
elif state == "failed":
error_msg = task_attrs.get("error", "Unknown error")
raise Exception(f"Task {task_id} failed: {error_msg}")
# The task's own failure text is an upstream body: a celery
# traceback, a provider message. Log it, never relay it.
logger.error(
f"Task {task_id} failed: {task_attrs.get('error', 'no error reported')}"
)
raise ToolError(f"Task {task_id} failed.")
elif state == "cancelled":
raise Exception(f"Task {task_id} was cancelled")
raise ToolError(f"Task {task_id} was cancelled")
# Wait before next poll
await asyncio.sleep(poll_interval)
@@ -330,12 +366,12 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
Parsed datetime object
Raises:
ValueError: If date format is invalid
InvalidArgument: If date format is invalid
"""
try:
return datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
raise ValueError(
raise InvalidArgument(
f"Invalid date format for {param_name}. Expected YYYY-MM-DD (e.g., '2025-01-15'), got '{date_str}'. "
f"Full date required - partial dates like '2025' or '2025-01' are not accepted."
)
@@ -347,10 +383,10 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
page_size: Page size to validate
Raises:
ValueError: If page size is out of valid range (1-1000)
InvalidArgument: If page size is out of valid range (1-1000)
"""
if page_size < 1 or page_size > 1000:
raise ValueError(
raise InvalidArgument(
f"Invalid page_size: {page_size}. Must be between 1 and 1000 (inclusive)."
)
@@ -373,7 +409,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
None if no dates provided, otherwise tuple of (date_from, date_to) as strings
Raises:
ValueError: If date range exceeds max_days or date format is invalid
InvalidArgument: If date range exceeds max_days or date format is invalid
"""
if not date_from and not date_to:
return None
@@ -394,7 +430,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
# Validate that date_from is before or equal to date_to
if from_date > to_date:
raise ValueError(
raise InvalidArgument(
f"Invalid date range: date_from must be before or equal to date_to. "
f"Got date_from='{from_date.date()}' and date_to='{to_date.date()}'. "
f"Please swap the dates or use the correct order."
@@ -403,7 +439,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
# Validate range doesn't exceed max_days
delta: int = (to_date - from_date).days + 1
if delta > max_days:
raise ValueError(
raise InvalidArgument(
f"Date range cannot exceed {max_days} days. "
f"Requested range: {from_date.date()} to {to_date.date()} ({delta} days)"
)
@@ -8,12 +8,21 @@ class SearchResult(BaseModel):
"""Search result model."""
path: str = Field(description="Document path")
title: str = Field(description="Document title")
url: str = Field(description="Documentation URL")
highlights: list[str] = Field(
description="Highlighted content snippets showing query matches with <mark><b> tags",
title: str = Field(description="Title of the page the match is on")
section: str = Field(
description="Heading of the section the match is in", default=""
)
breadcrumbs: list[str] = Field(
description="Where the page sits in the documentation, from the top-level group down to the page itself",
default_factory=list,
)
url: str = Field(
description="Documentation URL, anchored at the matching section when it has an anchor"
)
excerpt: str = Field(
description="Text of the matching section, which is a part of the page and not the whole of it",
default="",
)
score: float = Field(
description="Relevance score for the search result", default=0.0
)
@@ -24,12 +33,10 @@ class ProwlerDocsSearchEngine:
def __init__(self):
"""Initialize the search engine."""
self.api_base_url = (
"https://api.mintlifytrieve.com/api/chunk_group/group_oriented_autocomplete"
)
self.dataset_id = "0096ba11-3f72-463b-9d95-b788495ac392"
self.api_key = "tr-T6JLeTkFXeNbNPyhijtI9XhIncydQQ3O"
self.docs_base_url = "https://prowler.mintlify.app"
# The endpoint docs.prowler.com itself calls, with the site's Mintlify
# project name as the last segment.
self.api_base_url = "https://leaves.mintlify.com/api/search/prowler"
self.docs_base_url = "https://docs.prowler.com"
# HTTP client for Mintlify API
self.mintlify_client = httpx.Client(
@@ -38,9 +45,6 @@ class ProwlerDocsSearchEngine:
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": f"prowler-mcp-server/{__version__}",
"TR-Dataset": self.dataset_id,
"Authorization": self.api_key,
"X-API-Version": "V2",
},
)
@@ -59,75 +63,51 @@ class ProwlerDocsSearchEngine:
Args:
query: Search query string
page_size: Maximum number of results to return
page_size: Maximum number of results to return. The API decides how
many matches it answers with and takes no size of its own, so
this only trims the list it returned.
Returns:
list of search results
"""
try:
# Construct request body
payload = {
"query": query,
"search_type": "fulltext",
"extend_results": True,
"highlight_options": {
"highlight_window": 10,
"highlight_max_num": 1,
"highlight_max_length": 2,
"highlight_strategy": "exactmatch",
"highlight_delimiters": ["?", ",", ".", "!", "\n"],
},
"score_threshold": 0.2,
"filters": {"must_not": [{"field": "tag_set", "match": ["code"]}]},
"page_size": page_size,
"group_size": 3,
}
# Make request to Mintlify API
response = self.mintlify_client.post(
self.api_base_url,
json=payload,
json={"query": query, "filters": {}},
)
response.raise_for_status()
data = response.json()
# Parse results
results = []
for result in data.get("results", []):
group = result.get("group", {})
chunks = result.get("chunks", [])
for match in data.get("results", [])[:page_size]:
metadata = match.get("metadata", {})
breadcrumbs = metadata.get("breadcrumbs", [])
doc_path = match.get("page", "")
# Get document path and title from group
doc_path = group.get("name", "")
group_title = group.get("name", "").replace("/", " / ").title()
# 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
# If chunks exist, use the first chunk's title from metadata
title = group_title
if chunks:
first_chunk = chunks[0].get("chunk", {})
metadata = first_chunk.get("metadata", {})
title = metadata.get("title", group_title)
# Construct full URL to docs
full_url = f"{self.docs_base_url}/{doc_path}"
# Extract highlights and scores from chunks
highlights = []
max_score = 0.0
for chunk_data in chunks:
chunk_highlights = chunk_data.get("highlights", [])
highlights.extend(chunk_highlights)
# Track the highest score among all chunks in this group
chunk_score = chunk_data.get("score", 0.0)
max_score = max(max_score, chunk_score)
# 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,
url=full_url,
highlights=highlights,
score=max_score,
section=section,
breadcrumbs=breadcrumbs,
url=url,
excerpt=match.get("content", ""),
score=match.get("score", 0.0),
)
)
@@ -17,9 +17,9 @@ def search(
term: str = Field(description="The term to search for in the documentation"),
page_size: int = Field(
5,
description="Number of top results to return to return. It must be between 1 and 20.",
gt=1,
lt=20,
description="Number of top results to return. It must be between 1 and 20.",
ge=1,
le=20,
),
) -> list[dict[str, Any]]:
"""Search in Prowler documentation.
@@ -27,11 +27,12 @@ def search(
This tool searches through the official Prowler documentation
to find relevant information about everything related to Prowler.
Uses fulltext search to find the most relevant documentation pages
based on your query.
A result is one section of a documentation page, not the page itself: its
'excerpt' is that section alone. Read the whole page with
`prowler_docs_get_document`, passing the result's 'path'.
Returns:
List of search results with highlights showing matched terms (in <mark><b> tags)
List of matching documentation sections, most relevant first
"""
return prowler_docs_search_engine.search(term, page_size) # type: ignore In the hint we cannot put SearchResult type because JSON API MCP Generator cannot handle Pydantic models yet
+9 -1
View File
@@ -2,9 +2,17 @@ from fastmcp import FastMCP
from starlette.responses import JSONResponse
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.errors import SharedFailureMiddleware
from prowler_mcp_server.lib.logger import logger
prowler_mcp_server = FastMCP("prowler-mcp-server")
# `mask_error_details` keeps an unhandled failure from relaying text this server
# does not control. It is set on every sub-server as well, because it does not
# reach mounted children -- FastMCP warns about exactly that at mount time.
prowler_mcp_server = FastMCP("prowler-mcp-server", mask_error_details=True)
# Middleware, unlike masking, does reach mounted children, so the classifier that
# gives the masked failures a sentence back is wired here once.
prowler_mcp_server.add_middleware(SharedFailureMiddleware())
def setup_main_server():
+197
View File
@@ -0,0 +1,197 @@
"""Tests for the shared failure classifier.
Two properties are pinned here: a failed tool call answers with ``isError: true``
rather than a result object the client reads as a success, and the only text that
reaches a model is text this server produced.
"""
import json
import pytest
from fastmcp import Client
from pydantic import BaseModel, ValidationError
from prowler_mcp_server.lib.errors import InvalidArgument, _describe_failure
from prowler_mcp_server.prowler_app.utils.api_client import (
ProwlerAPIError,
ProwlerAPIInvalidResponse,
ProwlerAPIUnreachable,
)
from tests.helpers.jsonapi import jsonapi_error
LATEST = "/api/v1/findings/latest"
# ------------------------------------------------------------ classification
@pytest.mark.parametrize(
("status", "expected"),
[
(401, "missing, malformed or expired"),
(403, "prowler_get_current_user"),
(429, "rate limiting"),
(503, "failed on Prowler's side"),
],
ids=["unauthorized", "forbidden", "rate-limited", "unavailable"],
)
def test_the_failures_every_authenticated_tool_shares_get_one_message(status, expected):
"""These four mean the same thing whichever tool hit them."""
message = _describe_failure(ProwlerAPIError("failed", status))
assert expected in message
def test_a_rejection_relays_the_apis_own_reason():
"""`errors[].detail` is written by Prowler for a caller, so it is ours to relay."""
message = _describe_failure(
ProwlerAPIError("failed", 400, detail="scan_id is not a valid UUID.")
)
assert "scan_id is not a valid UUID." in message
def test_a_rejection_with_no_trustworthy_reason_says_so_instead_of_guessing():
"""A body that was not JSON:API leaves `detail` unset, and it stays unrelayed."""
message = _describe_failure(ProwlerAPIError("failed", 400))
assert "gave no reason" in message
def test_a_request_that_got_no_answer_says_the_outcome_is_unknown():
"""An unanswered write may well have landed, so repeating it can duplicate it."""
message = _describe_failure(ProwlerAPIUnreachable("POST /providers got no answer"))
assert "could not be reached" in message
assert "unknown" in message
def test_an_unreadable_api_answer_is_not_blamed_on_the_arguments():
"""The same `JSONDecodeError` means opposite things on the two sides."""
message = _describe_failure(
ProwlerAPIInvalidResponse(
"GET /findings answered 200 with a body that is not JSON"
)
)
assert "could not read" in message
assert "argument" not in message
def test_an_unreadable_api_answer_is_never_called_safe_to_repeat():
"""`post`, `patch` and `delete` reach this too, and a write may have landed."""
message = _describe_failure(
ProwlerAPIInvalidResponse(
"POST /providers answered 201 with a body that is not JSON"
)
)
assert "unknown" in message
assert "check the current state" 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(
InvalidArgument("page_size must be between 1 and 1000.")
)
assert message == "page_size must be between 1 and 1000."
def test_a_pydantic_rejection_names_the_field_without_echoing_the_value():
"""Pydantic quotes `input_value` back, and these tools take credentials."""
class Credentials(BaseModel):
api_token: int
with pytest.raises(ValidationError) as raised:
Credentials(api_token="hunter2-the-real-secret")
message = _describe_failure(raised.value)
assert "api_token" in message
assert "hunter2-the-real-secret" not in message
def test_unparseable_json_is_reported_without_quoting_the_input():
"""`JSONDecodeError` is a ValueError whose message quotes what it was given."""
with pytest.raises(json.JSONDecodeError) as raised:
json.loads('{"api_token": "hunter2-the-real-secret"')
message = _describe_failure(raised.value)
assert "could not be parsed" in message
assert "hunter2" not in message
def test_an_unrecognised_failure_is_left_masked():
"""Saying nothing is the safe default; the alternative is relaying anything."""
assert (
_describe_failure(RuntimeError("connection pool exhausted at 10.0.0.4:5432"))
is None
)
# --------------------------------------------------------- through the server
async def test_a_failing_tool_answers_with_is_error_not_a_result(
mcp_root_server, mock_api_client, mock_router
):
"""An error dict would arrive as `isError: false` and read as a success."""
mock_router.add("GET", LATEST, status=403, json=jsonapi_error(403, "Denied."))
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_search_security_findings", {})
assert result.isError is True
assert result.structuredContent is None
assert "prowler_get_current_user" in result.content[0].text
async def test_an_upstream_body_never_reaches_the_agent(
mcp_root_server, mock_api_client, mock_router
):
"""A body this server did not write is logged and replaced, never relayed."""
mock_router.add(
"GET",
LATEST,
status=500,
text="Traceback: psycopg2 could not connect to internal-db:5432",
)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_search_security_findings", {})
assert result.isError is True
assert "internal-db" not in result.content[0].text
assert "failed on Prowler's side" in result.content[0].text
async def test_a_bad_argument_is_rejected_before_any_request_goes_out(
mcp_root_server, mock_api_client, mock_router
):
"""Local validation saves a round trip, and its message is safe to repeat."""
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_search_security_findings", {"page_size": 5000}
)
assert result.isError is True
assert "Must be between 1 and 1000" in result.content[0].text
assert mock_router.requests == []
async def test_an_unreadable_api_answer_does_not_reach_the_agent_as_a_bad_argument(
mcp_root_server, mock_api_client, mock_router
):
"""Told apart by type, so the agent is not sent to fix an argument that is fine."""
mock_router.add("GET", LATEST, text="<html>gateway timeout</html>")
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_search_security_findings", {})
assert result.isError is True
assert "gateway timeout" not in result.content[0].text
assert "argument" not in result.content[0].text
@@ -7,9 +7,19 @@ query encoding and header assembly stay covered.
import httpx
import pytest
from fastmcp.exceptions import ToolError
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIError
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_error, jsonapi_resource
from prowler_mcp_server.prowler_app.utils.api_client import (
ProwlerAPIError,
ProwlerAPIInvalidResponse,
ProwlerAPIUnreachable,
)
from tests.helpers.jsonapi import (
jsonapi_collection,
jsonapi_error,
jsonapi_resource,
task_document,
)
from tests.helpers.tokens import FAKE_API_KEY
@@ -46,11 +56,7 @@ async def test_get_forwards_query_parameters(mock_api_client, mock_router):
async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_router):
"""A failed request is raised with the API's own `errors[].detail` message.
Tools relay this text straight to the model, so losing it turns an actionable
error into an opaque one.
"""
"""A failed request carries the API's own `errors[].detail`."""
mock_router.add(
"GET",
"/api/v1/findings/nope",
@@ -64,26 +70,112 @@ async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_
await mock_api_client.get("/findings/nope")
assert raised.value.status_code == 404
assert raised.value.detail == "Not found."
async def test_a_body_that_is_not_jsonapi_is_never_repeated(
mock_api_client, mock_router
):
"""A body that is not JSON:API leaves `detail` unset, so nothing is relayed."""
mock_router.add(
"GET",
"/api/v1/findings",
status=502,
text="<html><body>Traceback: secret-internal-host:5432</body></html>",
)
with pytest.raises(ProwlerAPIError) as raised:
await mock_api_client.get("/findings")
assert raised.value.detail is None
assert "secret-internal-host" not in str(raised.value)
async def test_a_server_error_detail_never_reaches_the_exception_text(
mock_api_client, mock_router
):
"""On a 5xx `errors[].detail` carries the failure, not a reason for a caller.
Tools that answer with `str(exc)` bypass the shared classifier, so the check
is on the exception itself rather than on the message the classifier builds.
"""
mock_router.add(
"GET",
"/api/v1/findings",
status=500,
json=jsonapi_error(
500, "OperationalError: could not connect to secret-internal-host:5432"
),
)
with pytest.raises(ProwlerAPIError) as raised:
await mock_api_client.get("/findings")
assert raised.value.detail is None
assert "secret-internal-host" not in str(raised.value)
async def test_an_unreadable_body_is_not_an_argument_failure(
mock_api_client, mock_router
):
"""A `JSONDecodeError` here is the API's doing, and must not read as ours."""
mock_router.add("GET", "/api/v1/findings", text="<html>gateway timeout</html>")
with pytest.raises(ProwlerAPIInvalidResponse) as raised:
await mock_api_client.get("/findings")
assert not isinstance(raised.value, ValueError)
assert "gateway timeout" not in str(raised.value)
async def test_a_mutation_with_an_unreadable_answer_still_raises(
mock_api_client, mock_router
):
"""`post` shares the parse, so a write cannot answer with an unread body."""
mock_router.add(
"POST", "/api/v1/providers", status=201, text="<html>accepted</html>"
)
with pytest.raises(ProwlerAPIInvalidResponse):
await mock_api_client.post("/providers", json_data={"data": {}})
async def test_a_request_that_got_no_answer_is_not_an_api_error(
mock_api_client, mock_router
):
"""`ProwlerAPIError` means the API answered, and callers act on that.
A write tool tells a rejected request -- which changed nothing -- from one
that may have been processed by the type of the failure, so a timeout must
not be dressed up as a rejection.
"""
"""`ProwlerAPIError` means the API answered, so a timeout must not use it."""
def timed_out(request):
raise httpx.ReadTimeout("Timed out reading the response", request=request)
mock_router.add_handler("GET", "/api/v1/findings", timed_out)
with pytest.raises(httpx.ReadTimeout):
with pytest.raises(ProwlerAPIUnreachable) as raised:
await mock_api_client.get("/findings")
assert not isinstance(raised.value, ProwlerAPIError)
async def test_a_failed_task_does_not_relay_its_own_error_text(
mock_api_client, mock_router
):
"""A failed task's error is an upstream body, so polling must not repeat it."""
mock_router.add(
"GET",
"/api/v1/tasks/t1",
json=task_document(
"t1",
"failed",
error="Traceback: connection to secret-internal-host:5432 refused",
),
)
with pytest.raises(ToolError) as raised:
await mock_api_client.poll_task_until_complete(task_id="t1", timeout=5)
assert "secret-internal-host" not in str(raised.value)
assert "t1" in str(raised.value)
def test_build_filter_params_normalises_types_for_the_api(mock_api_client):
"""Booleans become lowercase strings, sequences become CSV, `None` is dropped."""
@@ -0,0 +1,109 @@
"""Tests for the Prowler documentation search tool.
Mintlify moved the docs search to a new endpoint that answers with page
sections, so a result is a part of a page and has to read as one.
"""
import json
from fastmcp import Client
SEARCH = "/api/search/prowler"
def search_match(
path: str = "getting-started/installation",
*,
header: str = "Requirements",
breadcrumbs: tuple[str, ...] = ("Get Started", "Installation"),
anchor: str | None = "requirements",
score: float = 4.9,
):
"""One match as Mintlify answers with it: a section of a page, not the page."""
return {
"page": path,
"header": header,
"content": "Prowler runs on Python 3.9 or later.",
"metadata": {
"title": header,
"breadcrumbs": list(breadcrumbs),
"icon": "",
"hash": anchor,
"openapi": "",
},
"score": score,
}
def stub_search_hit(docs_router, *matches):
"""Serve the search endpoint, with one default match when none are given."""
if not matches:
matches = (search_match(),)
return docs_router.add("POST", SEARCH, json={"results": list(matches)})
async def test_search_returns_the_matching_sections(mcp_root_server, docs_router):
"""Every field of a result, since the shape of one changed with the endpoint."""
stub_search_hit(docs_router)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_docs_search", {"term": "install"})
match = result.data[0]
assert match["path"] == "getting-started/installation"
# The page's title, so a result reads as more than the heading it matched.
assert match["title"] == "Installation"
assert match["section"] == "Requirements"
assert match["breadcrumbs"] == ["Get Started", "Installation"]
assert match["excerpt"] == "Prowler runs on Python 3.9 or later."
assert match["score"] == 4.9
# Anchored: a match is a section, and the page it is on can be a long one.
assert match["url"] == (
"https://docs.prowler.com/getting-started/installation#requirements"
)
async def test_the_search_query_is_sent_as_the_api_expects_it(
mcp_root_server, docs_router
):
"""The endpoint takes a POST body, not the payload the old one took."""
stub_search_hit(docs_router)
async with Client(mcp_root_server) as client:
await client.call_tool("prowler_docs_search", {"term": "install"})
request = docs_router.request_for("POST", SEARCH)
assert json.loads(request.content) == {"query": "install", "filters": {}}
async def test_a_section_with_no_anchor_links_to_the_page(mcp_root_server, docs_router):
"""The API sends "" for a page's first section and null for pages without anchors."""
stub_search_hit(
docs_router,
search_match(anchor=""),
search_match(path="getting-started/requirements", anchor=None),
)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_docs_search", {"term": "install"})
assert result.data[0]["url"] == (
"https://docs.prowler.com/getting-started/installation"
)
assert result.data[1]["url"] == (
"https://docs.prowler.com/getting-started/requirements"
)
async def test_page_size_caps_a_response_the_api_did_not_size(
mcp_root_server, docs_router
):
"""The endpoint takes no size argument, so the cap has to be applied here."""
stub_search_hit(docs_router, *(search_match() for _ in range(6)))
async with Client(mcp_root_server) as client:
result = await client.call_tool(
"prowler_docs_search", {"term": "install", "page_size": 2}
)
assert len(result.data) == 2
+85 -39
View File
@@ -126,7 +126,10 @@ from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365
from prowler.lib.outputs.csv.csv import CSV
from prowler.lib.outputs.finding import Finding
from prowler.lib.outputs.html.html import HTML
from prowler.lib.outputs.ocsf.ingestion import send_ocsf_to_api
from prowler.lib.outputs.ocsf.ingestion import (
SystemTrustStoreError,
send_ocsf_to_api,
)
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.lib.outputs.outputs import extract_findings_statistics, report
from prowler.lib.outputs.sarif.sarif import SARIF
@@ -145,6 +148,7 @@ from prowler.providers.gcp.models import GCPOutputOptions
from prowler.providers.github.models import GithubOutputOptions
from prowler.providers.googleworkspace.models import GoogleWorkspaceOutputOptions
from prowler.providers.huaweicloud.models import HuaweiCloudOutputOptions
from prowler.providers.iac.exceptions.exceptions import IacBaseException
from prowler.providers.iac.models import IACOutputOptions
from prowler.providers.image.exceptions.exceptions import ImageBaseException
from prowler.providers.image.models import ImageOutputOptions
@@ -162,6 +166,75 @@ from prowler.providers.stackit.models import StackITOutputOptions
from prowler.providers.vercel.models import VercelOutputOptions
def _send_ocsf_to_cloud(file_path: str) -> dict | None:
"""Upload OCSF findings and report safe, actionable CLI failures."""
try:
return send_ocsf_to_api(file_path)
except requests.exceptions.JSONDecodeError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"the API returned an invalid JSON response. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except ValueError:
print(
f"{Style.BRIGHT}{Fore.YELLOW}\nPush to Prowler Cloud skipped: no API key configured. "
"Set the PROWLER_CLOUD_API_KEY environment variable to enable it. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except SystemTrustStoreError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the operating system trust store "
"could not be initialized. Check the configured CA bundle paths, verify the host certificate "
"hostname, validity period, and certificate chain, and install the organization or TLS-intercepting "
"proxy CA in the operating system trust store. In containers, configure and update the container "
"system CA store. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except requests.exceptions.SSLError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: TLS certificate validation failed. "
"Verify the hostname, validity period, and certificate chain, and install the organization or "
"TLS-intercepting proxy CA in the operating system trust store. In containers, configure and update "
"the container system CA store. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except requests.ConnectionError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: could not reach the Prowler Cloud API at "
f"{cloud_api_base_url}. Check the URL and your network connection. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except requests.HTTPError as http_err:
status_code = (
http_err.response.status_code if http_err.response is not None else None
)
if status_code == 402:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"this feature is only available with a Prowler Cloud subscription. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
elif status_code is None:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"the API request failed without a response status. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
else:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the API returned HTTP "
f"{status_code}. Verify your API key is valid and has the right permissions. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except Exception:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed unexpectedly. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
return None
def prowler():
# Parse Arguments
# Refactor(CLI)
@@ -493,12 +566,16 @@ def prowler():
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
elif provider == "iac":
try:
findings = global_provider.run()
except IacBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
else:
# IAC and external tool-wrapper providers registered via entry
# points. Unexpected failures propagate to the outer except
# Exception backstop further down in this file — keeping the
# branch free of an Image-specific catch that would otherwise
# mislead plug-in authors reading this code.
# External tool-wrapper providers registered via entry points.
# Unexpected failures propagate to the outer except Exception
# backstop further down in this file.
findings = global_provider.run()
# Note: External tool providers don't support granular progress tracking since
# they run external tools as a black box and return all findings at once.
@@ -641,39 +718,8 @@ def prowler():
print(
f"{Style.BRIGHT}\nPushing findings to Prowler Cloud, please wait...{Style.RESET_ALL}"
)
try:
response = send_ocsf_to_api(ocsf_output.file_path)
except ValueError:
print(
f"{Style.BRIGHT}{Fore.YELLOW}\nPush to Prowler Cloud skipped: no API key configured. "
"Set the PROWLER_CLOUD_API_KEY environment variable to enable it. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
except requests.ConnectionError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: could not reach the Prowler Cloud API at "
f"{cloud_api_base_url}. Check the URL and your network connection. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
except requests.HTTPError as http_err:
if http_err.response.status_code == 402:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"this feature is only available with a Prowler Cloud subscription. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
else:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the API returned HTTP {http_err.response.status_code}. "
"Verify your API key is valid and has the right permissions. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
except Exception as error:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed unexpectedly: {error}. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
else:
response = _send_ocsf_to_cloud(ocsf_output.file_path)
if response is not None:
job_id = response.get("data", {}).get("id") if response else None
if job_id:
print(
@@ -0,0 +1 @@
`defender_domain_dmarc_records_published` checks that every Exchange Online domain publishes a DMARC record with an enforcing policy (`p=quarantine` or `p=reject`)
@@ -0,0 +1 @@
Bedrock Agent ARNs are now built from the audited partition instead of a hardcoded `arn:aws:`, so findings in GovCloud and China carry a resolvable ARN and `--resource-arn` scoping matches agents in those partitions.
@@ -0,0 +1 @@
`bedrock_guardrail_contextual_grounding_filter_enabled`, `bedrock_custom_model_encrypted_with_cmk`, `bedrock_knowledge_base_encrypted_with_cmk` and `bedrock_agent_role_not_shared_across_agents` are four new AWS Bedrock checks covering guardrail contextual grounding, custom model encryption, knowledge-base data-source encryption, and non-shared agent execution roles.
@@ -0,0 +1 @@
NCSC Cyber Essentials 3.3 compliance framework with Azure provider coverage across the five Cyber Essentials themes
@@ -0,0 +1 @@
`ec2_securitygroup_not_used` no longer reports a false positive for security groups attached only to an AWS Batch compute environment, which holds them in configuration without creating a network interface while scaled down to zero instances
@@ -0,0 +1 @@
AWS FSBP compliance mapping for `IAM.9` and `EKS.1` referenced missing/renamed checks; both now point to their real, existing check IDs
@@ -0,0 +1 @@
IaC provider now raises typed `IacBaseException` errors (repository clone, Trivy missing, scan and output processing failures) instead of calling `sys.exit(1)`; the CLI still stops with the logged message, and API scans fail as regular task errors instead of a `SystemExit` escaping the worker
@@ -0,0 +1 @@
`Cluster` column in Kubernetes CIS, ISO27001, Prowler ThreatScore, and universal compliance outputs, populated with the resolved cluster name so multi-cluster scans can be told apart in the output
@@ -0,0 +1 @@
Kubernetes `kubelet` checks no longer disappear from the scan with `TypeError: 'NoneType' object is not iterable` when a `kubelet-config` ConfigMap is broken: one with malformed YAML is logged and skipped while the valid ones are still evaluated, one without kubelet data is evaluated with an empty configuration instead of crashing the checks, and the `apiserver`, `controllermanager`, `etcd` and `scheduler` pod gatherers now always return a list
@@ -0,0 +1 @@
`organization_actions_pull_request_approval_disabled` check for GitHub provider, verifying that organizations prevent GitHub Actions from creating and approving pull requests
@@ -0,0 +1 @@
`organization_default_workflow_permissions_read_only` check for GitHub provider, verifying that organizations grant GitHub Actions workflows a read-only default `GITHUB_TOKEN`
@@ -0,0 +1 @@
`oss_bucket_server_side_encryption_enabled` check for Alibaba Cloud provider, verifying that OSS buckets have a default server-side encryption rule (AES256 or KMS)
@@ -0,0 +1 @@
OSS bucket logging, versioning, default encryption and ACL configurations are now read correctly from the Alibaba Cloud SDK, so `oss_bucket_logging_enabled`, `oss_bucket_versioning_enabled`, `oss_bucket_server_side_encryption_enabled` and `oss_bucket_not_publicly_accessible` no longer report every bucket as unconfigured
@@ -0,0 +1 @@
`oss_bucket_versioning_enabled` check for Alibaba Cloud provider, verifying that OSS buckets have versioning enabled to allow recovery from accidental or malicious object overwrite and deletion
@@ -0,0 +1 @@
`push-to-cloud` now validates Private Cloud TLS certificates with the operating system trust store without changing provider HTTP clients
@@ -0,0 +1 @@
`repository_default_workflow_permissions_read_only` check for GitHub provider, verifying that repositories grant GitHub Actions workflows a read-only default `GITHUB_TOKEN`
@@ -0,0 +1 @@
`openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the SDK container image, patching ten high OpenSSL CVEs
@@ -0,0 +1 @@
`ske_cluster_no_public_endpoint` check for STACKIT provider, flagging SKE clusters whose Kubernetes API endpoint is reachable from the whole internet because the ACL extension is disabled or its allowed CIDR list contains `0.0.0.0/0` or `::/0`
@@ -0,0 +1 @@
CLI Slack integration (`--slack`) no longer fails when a scan produces no findings: the pass and fail percentages are guarded against a `findings_count` of 0, which previously raised `ZeroDivisionError` and sent `blocks=None` to Slack instead of the summary
@@ -1 +0,0 @@
Supabase provider with the `organizations_member_mfa_enabled` check
@@ -0,0 +1 @@
`prowler.compliance.universal` entry point directories are resolved through a single shared helper and deduplicated by resolved path, so a directory reached through two entry points is parsed once and a package that fails to import no longer hides the rest
@@ -0,0 +1 @@
`vpc_security_group_open_egress` check for Huawei Cloud provider: VPC security groups do not allow open egress to the internet
@@ -110,7 +110,9 @@
"eks_cluster_kms_cmk_encryption_in_secrets_enabled",
"dynamodb_tables_kms_cmk_encryption_enabled",
"sns_topics_kms_encryption_at_rest_enabled",
"sqs_queues_server_side_encryption_enabled"
"sqs_queues_server_side_encryption_enabled",
"bedrock_custom_model_encrypted_with_cmk",
"bedrock_knowledge_base_encrypted_with_cmk"
]
},
{
@@ -638,10 +640,12 @@
"Section": "AI Application Security",
"SubSection": "Output Validation",
"Service": "bedrock",
"Type": "Manual"
"Type": "Automated"
}
],
"Checks": []
"Checks": [
"bedrock_guardrail_contextual_grounding_filter_enabled"
]
},
{
"Id": "AISF-AI-10",
@@ -652,10 +656,12 @@
"Section": "AI Application Security",
"SubSection": "RAG Security",
"Service": "bedrock",
"Type": "Manual"
"Type": "Automated"
}
],
"Checks": []
"Checks": [
"bedrock_knowledge_base_encrypted_with_cmk"
]
},
{
"Id": "AISF-AI-11",
@@ -680,10 +686,12 @@
"Section": "AI Application Security",
"SubSection": "Agent Governance",
"Service": "bedrock",
"Type": "Manual"
"Type": "Automated"
}
],
"Checks": []
"Checks": [
"bedrock_agent_role_not_shared_across_agents"
]
},
{
"Id": "AISF-AGENT-02",
@@ -1782,7 +1782,7 @@
"Name": "EKS cluster endpoints should not be publicly accessible",
"Description": "This control checks whether an Amazon EKS cluster endpoint is publicly accessible. The control fails if an EKS cluster has an endpoint that is publicly accessible.",
"Checks": [
"eks_endpoints_not_publicly_accessible"
"eks_cluster_not_publicly_accessible"
],
"Attributes": [
{
@@ -2634,7 +2634,9 @@
"Id": "IAM.9",
"Name": "MFA should be enabled for the root user",
"Description": "The root user has complete access to all the services and resources in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled, when a user signs in to the AWS Management Console, they're prompted for their user name and password and for an authentication code from their AWS MFA device.",
"Checks": [],
"Checks": [
"iam_root_mfa_enabled"
],
"Attributes": [
{
"ItemId": "IAM.9",
@@ -0,0 +1,605 @@
{
"framework": "Cyber-Essentials",
"name": "NCSC Cyber Essentials: Requirements for IT Infrastructure",
"version": "3.3",
"description": "The UK National Cyber Security Centre (NCSC) Cyber Essentials scheme - Requirements for IT Infrastructure v3.3 (April 2026). Cyber Essentials organises its technical requirements into five control themes: Firewalls, Secure Configuration, Security Update Management, User Access Control, and Malware Protection. Cloud services are explicitly in scope for Cyber Essentials and cannot be excluded, so under the shared responsibility model this framework covers the controls the applicant organisation is responsible for implementing on its own cloud infrastructure (Infrastructure/Platform as a Service). Requirements that apply to end-user devices, on-premises network appliances, or organisational process and are not observable from cloud control-plane evidence are included for completeness with an empty check list and 'non-applicable' or 'Manual' attributes.",
"icon": "cyber-essentials",
"attributes_metadata": [
{
"key": "Theme",
"label": "Technical Control Theme",
"type": "str",
"required": true,
"enum": [
"Firewalls",
"Secure Configuration",
"Security Update Management",
"User Access Control",
"Malware Protection"
],
"output_formats": {
"csv": true,
"ocsf": true
}
},
{
"key": "AssessmentStatus",
"label": "Assessment Status",
"type": "str",
"required": true,
"enum": [
"Automated",
"Manual"
],
"output_formats": {
"csv": true,
"ocsf": true
}
},
{
"key": "CloudApplicability",
"label": "Cloud Applicability",
"type": "str",
"required": true,
"enum": [
"full",
"partial",
"non-applicable"
],
"output_formats": {
"csv": true,
"ocsf": true
}
},
{
"key": "RemediationProcedure",
"label": "Remediation Procedure",
"type": "str",
"required": true,
"output_formats": {
"csv": true,
"ocsf": false
}
},
{
"key": "References",
"label": "References",
"type": "str",
"required": true,
"output_formats": {
"csv": true,
"ocsf": false
}
}
],
"outputs": {
"table_config": {
"group_by": "Theme"
},
"pdf_config": {
"language": "en",
"primary_color": "#003D54",
"secondary_color": "#0072CE",
"bg_color": "#F0F4FA",
"group_by_field": "Theme",
"sections": [
"Firewalls",
"Secure Configuration",
"Security Update Management",
"User Access Control",
"Malware Protection"
],
"section_short_names": {
"Firewalls": "Firewalls",
"Secure Configuration": "Secure Config",
"Security Update Management": "Update Mgmt",
"User Access Control": "Access Control",
"Malware Protection": "Malware"
},
"charts": [
{
"id": "theme_compliance",
"type": "horizontal_bar",
"group_by": "Theme",
"title": "Compliance Score by Cyber Essentials Theme",
"y_label": "Theme",
"x_label": "Compliance %",
"value_source": "compliance_percent",
"color_mode": "by_value"
}
],
"filter": {
"only_failed": true,
"include_manual": false
}
}
},
"requirements": [
{
"id": "CE-FW-01",
"name": "Boundary firewall on every in-scope device",
"description": "Every device in scope must be protected by a correctly configured firewall or network device with firewall functionality, restricting inbound and outbound network services to those that are secure and necessary.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Use Network Security Groups (or Azure Firewall) on every subnet/NIC and remove rules that allow unrestricted inbound access from the internet to RDP, SSH, and other management or data services.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"network_rdp_internet_access_restricted",
"network_ssh_internet_access_restricted",
"network_udp_internet_access_restricted",
"network_http_internet_access_restricted"
]
}
},
{
"id": "CE-FW-02",
"name": "Change default administrative passwords or disable remote admin access",
"description": "Default administrative passwords on firewalls and network devices must be changed to a strong, unique password, or remote administrative access must be disabled entirely.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This requirement applies to on-premises network appliances and home/remote routers, which are outside the scope of cloud control-plane evidence. Manage and document this control as part of your organisation's device estate.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-FW-03",
"name": "Restrict access to firewall/network device administrative interfaces",
"description": "Access to the administrative interface used to manage firewall or network device configuration must not be possible from the internet, unless there is a documented business need and the interface is protected by MFA or an IP allow list combined with a managed password approach.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Automated",
"CloudApplicability": "full",
"RemediationProcedure": "Disable public network access on management-plane resources (storage accounts, Key Vaults) or restrict access to trusted networks/IP ranges, and require MFA for any administrative access exposed to the internet.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"storage_account_public_network_access_disabled",
"storage_default_network_access_rule_is_denied",
"keyvault_access_only_through_private_endpoints",
"network_rdp_internet_access_restricted",
"network_ssh_internet_access_restricted"
]
}
},
{
"id": "CE-FW-04",
"name": "Block unauthenticated inbound connections by default",
"description": "Firewalls and network devices must block unauthenticated inbound connections by default.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Automated",
"CloudApplicability": "full",
"RemediationProcedure": "Configure Network Security Group rules with a default-deny inbound posture and only allow specific, documented inbound services. Disable public network access on PaaS resources that do not require it.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"network_rdp_internet_access_restricted",
"network_ssh_internet_access_restricted",
"network_udp_internet_access_restricted",
"network_http_internet_access_restricted",
"storage_account_public_network_access_disabled"
]
}
},
{
"id": "CE-FW-05",
"name": "Inbound firewall rules approved and documented",
"description": "Inbound firewall rules must be approved and documented by an authorised person, including the business need for the rule.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Maintain a change-approval record (e.g. change tickets or a network rule register) for every inbound Network Security Group rule, including the business justification and approver.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-FW-06",
"name": "Remove or disable unnecessary firewall rules",
"description": "Firewall rules that are no longer needed must be removed or disabled.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Periodically review firewall and network access-control rules, removing or disabling any rule that no longer has a documented business need.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-FW-07",
"name": "Software firewall on devices used on untrusted networks",
"description": "Devices that connect to untrusted networks, such as public Wi-Fi hotspots, must use a software firewall.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This is an end-user device control (host-based firewall) and has no cloud control-plane equivalent. Enforce via endpoint management policy.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SC-01",
"name": "Remove or disable unnecessary user accounts",
"description": "Unnecessary user accounts, such as guest accounts and unused administrative accounts, must be removed or disabled.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Restrict guest user invitations and access, and review Microsoft Entra ID and Azure RBAC role assignments to remove unused guest or administrative accounts.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_policy_guest_users_access_restrictions",
"entra_policy_guest_invite_only_for_admin_roles",
"iam_role_user_access_admin_restricted"
]
}
},
{
"id": "CE-SC-02",
"name": "Change default or guessable account passwords",
"description": "Default or guessable account passwords must be changed before a device or service is used.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable Microsoft Entra ID security defaults (or an equivalent Conditional Access baseline) so that default/weak credentials cannot be used for sign-in.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_security_defaults_enabled"
]
}
},
{
"id": "CE-SC-03",
"name": "Remove or disable unnecessary software",
"description": "Unnecessary software, including applications, system utilities and network services, must be removed or disabled.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This is an operating-system level control for devices and servers and has no direct cloud control-plane equivalent. Maintain an approved software baseline and image hardening process for VM images.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SC-04",
"name": "Disable auto-run features",
"description": "Auto-run features that allow file execution without user authorisation must be disabled.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This is an operating-system level control and has no cloud control-plane equivalent. Disable AutoRun/AutoPlay via OS configuration or group policy on VM images.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SC-05",
"name": "Authenticate users before granting access to organisational data or services",
"description": "Users must be authenticated before being allowed access to organisational data or services, including cloud services.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Automated",
"CloudApplicability": "full",
"RemediationProcedure": "Disable anonymous/public access to storage and require authenticated, encrypted (TLS 1.2+) access. Use Azure RBAC for Key Vault data-plane access instead of access policies that allow unauthenticated retrieval.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"storage_secure_transfer_required_is_enabled",
"storage_blob_public_access_level_is_disabled",
"storage_account_public_network_access_disabled",
"storage_ensure_minimum_tls_version_12",
"keyvault_rbac_enabled"
]
}
},
{
"id": "CE-SC-06",
"name": "Device locking and brute-force protection for unlocking credentials",
"description": "Devices that require a user's physical presence must use an unlocking credential (biometric, password or PIN) of at least 6 characters, protected against brute-force guessing by throttling or lockout after no more than 10 attempts.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enforce key-based SSH authentication on Linux VMs (disabling password authentication) and enable Microsoft Entra ID security defaults to apply baseline sign-in protections.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"vm_linux_enforce_ssh_authentication",
"entra_security_defaults_enabled"
]
}
},
{
"id": "CE-SUM-01",
"name": "All software licensed and supported",
"description": "All software on in-scope devices must be licensed and supported by the vendor.",
"attributes": {
"Theme": "Security Update Management",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Maintain a software asset inventory confirming each product is licensed and has an active vendor support commitment, including operating systems and any third-party software running on cloud compute resources.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SUM-02",
"name": "Remove unsupported software",
"description": "Software must be removed from devices when it becomes unsupported, or isolated into a defined sub-set that prevents all internet traffic.",
"attributes": {
"Theme": "Security Update Management",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Track vendor end-of-support dates for operating systems and applications running on cloud compute resources, and decommission or isolate workloads before support ends.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SUM-03",
"name": "Automatic updates enabled where possible",
"description": "Automatic updates must be enabled on in-scope software where this is possible.",
"attributes": {
"Theme": "Security Update Management",
"AssessmentStatus": "Manual",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable automatic updates on in-scope software where possible: configure platform-managed guest patching on cloud virtual machines and instance groups, and enable vendor auto-update mechanisms for any third-party software running on those workloads. Note: no check currently observes whether automatic updates are enabled (vulnerability assessment and security-posture provisioning only prove monitoring and assessment coverage), so this requirement must be verified manually until a dedicated check validating update automation exists.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SUM-04",
"name": "Critical and high-risk updates applied within 14 days",
"description": "Updates that fix vulnerabilities described by the vendor as critical or high risk, or that address a CVSS v3 base score of 7 or above (or where severity is unspecified), must be applied within 14 days of release.",
"attributes": {
"Theme": "Security Update Management",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable Microsoft Defender for Cloud system update recommendations and vulnerability assessment, and remediate flagged virtual machines within 14 days of a critical or high-risk update being released.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"defender_ensure_system_updates_are_applied",
"defender_auto_provisioning_vulnerabilty_assessments_machines_on"
]
}
},
{
"id": "CE-UAC-01",
"name": "Process to create and approve user accounts",
"description": "A documented process must be in place to create and approve user accounts before access is granted.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Maintain a joiner/mover/leaver process with documented approval steps for creating identity-provider accounts and assigning cloud roles.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-UAC-02",
"name": "Authenticate users with unique credentials",
"description": "Users must be authenticated with unique credentials before being granted access to applications or devices.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable Microsoft Entra ID security defaults and prefer Entra ID authentication (over shared keys) for Azure resources such as storage accounts, so every user authenticates with their own unique identity.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_security_defaults_enabled",
"storage_default_to_entra_authorization_enabled"
]
}
},
{
"id": "CE-UAC-03",
"name": "Remove or disable user accounts when no longer required",
"description": "User accounts must be removed or disabled when they are no longer required, for example when a user leaves the organisation or after a defined period of inactivity.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Implement a leaver process and periodic access reviews (e.g. Microsoft Entra ID access reviews) to disable or remove accounts that are no longer required.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-UAC-04",
"name": "Multi-factor authentication for cloud services",
"description": "Multi-factor authentication must be implemented where available, and authentication to cloud services must always use MFA.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "full",
"RemediationProcedure": "Require multi-factor authentication for all users via Conditional Access policies, covering admin portals, the Azure management API, and users with access to virtual machines.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_privileged_user_has_mfa",
"entra_non_privileged_user_has_mfa",
"entra_conditional_access_policy_require_mfa_for_admin_portals",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_user_with_vm_access_has_mfa"
]
}
},
{
"id": "CE-UAC-05",
"name": "Separate accounts for administrative activities",
"description": "Separate accounts must be used to perform administrative activities only, with no email, web browsing or other standard user activity that could expose administrative privileges to avoidable risk.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Limit the number of Global Administrator assignments, avoid granting the User Access Administrator or subscription Owner role broadly, and require named administrators to use dedicated privileged accounts for administrative tasks.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_global_admin_in_less_than_five_users",
"iam_role_user_access_admin_restricted",
"iam_subscription_roles_owner_custom_not_created"
]
}
},
{
"id": "CE-UAC-06",
"name": "Remove or disable special access privileges when no longer required",
"description": "Special access privileges must be removed or disabled when they are no longer required, for example when a member of staff changes role.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Periodically review Microsoft Entra ID directory role assignments and Azure RBAC role assignments, removing privileged roles that are no longer needed for a user's current role.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_global_admin_in_less_than_five_users",
"iam_role_user_access_admin_restricted"
]
}
},
{
"id": "CE-UAC-07",
"name": "Protect password-based authentication against brute-force attacks",
"description": "Where authentication is carried out using a password, accounts must be protected against brute-force guessing using MFA, attempt throttling, or account lockout.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable Microsoft Entra ID security defaults (which include smart lockout) and require MFA, particularly for privileged accounts, to mitigate brute-force password attacks.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_security_defaults_enabled",
"entra_privileged_user_has_mfa"
]
}
},
{
"id": "CE-UAC-08",
"name": "Technical controls to manage password quality",
"description": "Technical controls must be used to manage the quality of passwords, using MFA, a minimum password length of at least 12 characters with no maximum length, or a minimum of 8 characters combined with a common-password deny list. Regular forced password expiry and complexity requirements should not be enforced.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable Microsoft Entra ID security defaults and Microsoft Entra ID Password Protection (banned password list), and require MFA so that password length alone is not the only protection.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_security_defaults_enabled",
"entra_privileged_user_has_mfa",
"entra_non_privileged_user_has_mfa"
]
}
},
{
"id": "CE-MP-01",
"name": "Malware protection mechanism active on all in-scope devices",
"description": "A malware protection mechanism must be active on all devices in scope, using anti-malware software, application allow listing, or application sandboxing.",
"attributes": {
"Theme": "Malware Protection",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Ensure endpoint protection is installed on all virtual machines, enable Microsoft Defender for Endpoint integration, and enable Microsoft Defender for Servers.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"defender_assessments_vm_endpoint_protection_installed",
"defender_ensure_wdatp_is_enabled",
"defender_ensure_defender_for_server_is_on"
]
}
},
{
"id": "CE-MP-02",
"name": "Anti-malware software configuration",
"description": "Anti-malware software must be kept up to date in line with vendor recommendations, prevent malware from running, prevent execution of malicious code, and prevent connections to malicious websites.",
"attributes": {
"Theme": "Malware Protection",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable Microsoft Defender for Endpoint, Microsoft Defender for Servers, and Microsoft Defender for Storage so that signatures stay current and malicious files, code execution and connections are blocked.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"defender_ensure_wdatp_is_enabled",
"defender_ensure_defender_for_server_is_on",
"defender_ensure_defender_for_storage_is_on"
]
}
},
{
"id": "CE-MP-03",
"name": "Application allow listing",
"description": "Where used as an alternative to anti-malware software, only approved applications restricted by code signing must be allowed to execute, with a maintained list of approved applications and no execution of unsigned or invalidly signed applications.",
"attributes": {
"Theme": "Malware Protection",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This is an end-user device control implemented through application control policies (e.g. Microsoft Defender Application Control) and has no cloud control-plane equivalent.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
}
]
}
+3 -1
View File
@@ -526,7 +526,9 @@
{
"Id": "2.1.10",
"Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.",
"Checks": [],
"Checks": [
"defender_domain_dmarc_records_published"
],
"Attributes": [
{
"Section": "2 Microsoft 365 Defender",
+3 -1
View File
@@ -543,7 +543,9 @@
{
"Id": "2.1.10",
"Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.",
"Checks": [],
"Checks": [
"defender_domain_dmarc_records_published"
],
"Attributes": [
{
"Section": "2 Microsoft 365 Defender",

Some files were not shown because too many files have changed in this diff Show More