mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(mcp): raise instead of returning error objects in the Prowler Hub tools (#12533)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
`prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now report a check ID that belongs to another provider as such, naming that provider, instead of reporting the ID as one that does not exist
|
||||
@@ -55,6 +55,59 @@ class ProwlerAPIInvalidResponse(Exception):
|
||||
"""The API answered, but with a body this server could not read as JSON."""
|
||||
|
||||
|
||||
class UpstreamInvalidResponse(Exception):
|
||||
"""An upstream this server reads directly answered with a body that is not JSON.
|
||||
|
||||
Raised in place of the `json.JSONDecodeError` httpx would otherwise let out.
|
||||
That one is a ValueError this module reads as a malformed argument, which is
|
||||
the opposite story: it sends a model off to fix a call that was fine.
|
||||
|
||||
Attributes:
|
||||
host: Host that answered, so the message can name what has to be fixed
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, host: str) -> None:
|
||||
super().__init__(message)
|
||||
self.host: str = host
|
||||
|
||||
|
||||
def parse_json_response(response: httpx.Response) -> Any:
|
||||
"""Parse an upstream answer as JSON, telling an unreadable body from a bad
|
||||
argument.
|
||||
|
||||
For every upstream a sub-server reads with an httpx client of its own --
|
||||
Prowler Hub, the documentation site. `httpx` lets a body it cannot decode
|
||||
out as a `json.JSONDecodeError`, which is a ValueError this module reads as
|
||||
a malformed argument. Coming from an upstream -- an HTML error page from an
|
||||
edge, a truncated body -- that is the wrong story, and the caller has no
|
||||
argument to fix.
|
||||
|
||||
The Prowler API client parses its own answers and raises
|
||||
`ProwlerAPIInvalidResponse` instead: it also carries writes, where an
|
||||
unreadable answer leaves the outcome unknown rather than merely absent.
|
||||
|
||||
Args:
|
||||
response: The answer to parse.
|
||||
|
||||
Returns:
|
||||
The parsed body.
|
||||
|
||||
Raises:
|
||||
UpstreamInvalidResponse: The body is not JSON.
|
||||
"""
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as e:
|
||||
# `.request` raises rather than returning None when it was never set.
|
||||
request = getattr(response, "_request", None)
|
||||
host = request.url.host if request is not None else "The upstream service"
|
||||
# Status only: the decoder's own message quotes the body it choked on,
|
||||
# and that body is the upstream text this server never relays.
|
||||
raise UpstreamInvalidResponse(
|
||||
f"{response.status_code} body is not JSON", host=host
|
||||
) from e
|
||||
|
||||
|
||||
def jsonapi_detail(response: httpx.Response) -> str | None:
|
||||
"""Return the API's own JSON:API error detail, when there is one to trust.
|
||||
|
||||
@@ -171,6 +224,17 @@ def _describe_failure(exc: BaseException) -> str | None:
|
||||
"current state before sending it again."
|
||||
)
|
||||
|
||||
if isinstance(exc, UpstreamInvalidResponse):
|
||||
# The counterpart of the `json.JSONDecodeError` branch below: the same
|
||||
# decode failure is a malformed argument on one side of this server and
|
||||
# an upstream fault on the other, and only the type tells them apart.
|
||||
return (
|
||||
f"{exc.host} answered with a body this server could not read as JSON, "
|
||||
"so the call has no result to return. Nothing in the arguments caused "
|
||||
f"this and changing them will not help -- {exc.host} is answering with "
|
||||
"something other than the JSON it documents. Retry later."
|
||||
)
|
||||
|
||||
if isinstance(exc, CredentialError):
|
||||
# Not an argument problem, so it is worth saying that plainly: the
|
||||
# answer is a credential the user has to fix, not another attempt.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""URL construction shared by every sub-server.
|
||||
|
||||
An identifier joined into a path unencoded is not sent as itself: httpx resolves
|
||||
the URL per RFC 3986, so "../" walks the request onto another endpoint.
|
||||
"""
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
_DOT_SEGMENTS = frozenset({".", ".."})
|
||||
|
||||
|
||||
def path_segment(value: str) -> str:
|
||||
"""Encode one path segment, so an identifier names a resource and nothing else.
|
||||
|
||||
Args:
|
||||
value: The segment to encode, taken as a name in full.
|
||||
|
||||
Returns:
|
||||
The segment percent-encoded, with the dots escaped when it is only dots.
|
||||
"""
|
||||
encoded = quote(value, safe="")
|
||||
# A dot is legal in a name, so `quote` keeps it: a segment of nothing but
|
||||
# dots would still resolve away rather than name anything.
|
||||
return encoded.replace(".", "%2E") if encoded in _DOT_SEGMENTS else encoded
|
||||
|
||||
|
||||
def url_path(*segments: str) -> str:
|
||||
"""Build a URL path from one argument per segment, each of them encoded.
|
||||
|
||||
Args:
|
||||
*segments: The path segments, in order.
|
||||
|
||||
Returns:
|
||||
The joined path, with a leading slash.
|
||||
"""
|
||||
return "/" + "/".join(path_segment(segment) for segment in segments)
|
||||
@@ -6,13 +6,19 @@ Provides access to Prowler Hub API for security checks and compliance frameworks
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server import __version__
|
||||
from prowler_mcp_server.lib.errors import (
|
||||
UpstreamInvalidResponse,
|
||||
parse_json_response,
|
||||
)
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
from prowler_mcp_server.lib.urls import url_path
|
||||
|
||||
# Initialize FastMCP for Prowler Hub
|
||||
hub_mcp_server = FastMCP("prowler-hub")
|
||||
hub_mcp_server = FastMCP("prowler-hub", mask_error_details=True)
|
||||
|
||||
# API base URL
|
||||
BASE_URL = "https://hub.prowler.com/api"
|
||||
@@ -27,6 +33,19 @@ prowler_hub_client = httpx.Client(
|
||||
},
|
||||
)
|
||||
|
||||
# Sentences for the not-found cases. They are authored here, and raised as a
|
||||
# ToolError without a `from` clause, because they name the resource the caller
|
||||
# asked for and the next tool to reach for -- neither of which the shared
|
||||
# classifier in lib/errors.py can know.
|
||||
_CHECK_NOT_FOUND = (
|
||||
"No check with the ID '{check_id}' exists in Prowler Hub. Use "
|
||||
"prowler_hub_semantic_search_checks to find the right ID."
|
||||
)
|
||||
_COMPLIANCE_NOT_FOUND = (
|
||||
"No compliance framework with the ID '{compliance_id}' exists in Prowler Hub. "
|
||||
"Use prowler_hub_semantic_search_compliances to find the right ID."
|
||||
)
|
||||
|
||||
# GitHub raw content base URL for Prowler checks
|
||||
GITHUB_RAW_BASE = (
|
||||
"https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/"
|
||||
@@ -43,6 +62,21 @@ github_raw_client = httpx.Client(
|
||||
)
|
||||
|
||||
|
||||
def _get_hub_endpoint(
|
||||
*path_segments: str, params: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
"""GET a Prowler Hub endpoint, named as one argument per path segment.
|
||||
|
||||
Args:
|
||||
*path_segments: The endpoint path segments, in order.
|
||||
params: Query parameters for the request.
|
||||
|
||||
Returns:
|
||||
The response unread, so a caller can tell a 404 from a failed request.
|
||||
"""
|
||||
return prowler_hub_client.get(url_path(*path_segments), params=params)
|
||||
|
||||
|
||||
def github_check_path(provider_id: str, check_id: str, suffix: str) -> str:
|
||||
"""Build the GitHub raw URL for a given check artifact suffix using provider
|
||||
and check_id.
|
||||
@@ -53,7 +87,83 @@ def github_check_path(provider_id: str, check_id: str, suffix: str) -> str:
|
||||
service_id = check_id.split("_", 1)[0]
|
||||
except IndexError:
|
||||
service_id = check_id
|
||||
return f"{GITHUB_RAW_BASE}/{provider_id}/services/{service_id}/{check_id}/{check_id}{suffix}"
|
||||
path = url_path(provider_id, "services", service_id, check_id, check_id)
|
||||
return f"{GITHUB_RAW_BASE}{path}{suffix}"
|
||||
|
||||
|
||||
def _hub_provider_for_check(check_id: str) -> str | None:
|
||||
"""Ask Prowler Hub which provider it lists a check under.
|
||||
|
||||
Args:
|
||||
check_id: Check ID the caller asked for
|
||||
|
||||
Returns:
|
||||
The provider the Hub lists the check under, or None when the Hub knows
|
||||
no such check.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: The Hub could not be reached.
|
||||
UpstreamInvalidResponse: The Hub answered with a body that is not JSON.
|
||||
ValueError: The Hub answered with something that names no provider.
|
||||
"""
|
||||
response = _get_hub_endpoint("check", check_id)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
check = parse_json_response(response)
|
||||
|
||||
# An empty body is how the Hub reports an unknown ID on some routes, so it
|
||||
# is read the same way get_check_details reads it: no such check.
|
||||
if not isinstance(check, dict) or not check:
|
||||
return None
|
||||
|
||||
provider = check.get("provider")
|
||||
if isinstance(provider, str) and provider.strip():
|
||||
return provider
|
||||
# A check the Hub returned without a provider tells us nothing about the
|
||||
# provider the caller asked for, so it counts as unanswered rather than as
|
||||
# a check that does not exist.
|
||||
raise ValueError(f"Prowler Hub listed check '{check_id}' without a provider")
|
||||
|
||||
|
||||
def _explain_missing_check_file(
|
||||
provider_id: str,
|
||||
check_id: str,
|
||||
*,
|
||||
when_check_belongs_here: str,
|
||||
when_unverified: str,
|
||||
) -> str:
|
||||
"""Explain a 404 from GitHub for one of a check's source files.
|
||||
|
||||
GitHub answers 404 to three different mistakes, an ID that exists nowhere,
|
||||
an ID that exists under a different provider, and an ID that exists right
|
||||
here whose file is simply absent, and cannot tell them apart. Prowler Hub
|
||||
can, so it is asked before anything is claimed about the ID.
|
||||
|
||||
Args:
|
||||
provider_id: Provider the caller asked for
|
||||
check_id: Check the caller asked for
|
||||
when_check_belongs_here: Message for the case where the Hub confirms the
|
||||
check does belong to this provider
|
||||
when_unverified: Message for the case where the Hub could not be asked
|
||||
|
||||
Returns:
|
||||
The sentence to fail the tool with
|
||||
"""
|
||||
try:
|
||||
hub_provider = _hub_provider_for_check(check_id)
|
||||
except (httpx.HTTPError, UpstreamInvalidResponse, ValueError):
|
||||
return when_unverified
|
||||
|
||||
if hub_provider is None:
|
||||
return _CHECK_NOT_FOUND.format(check_id=check_id)
|
||||
if hub_provider != provider_id:
|
||||
return (
|
||||
f"Provider '{provider_id}' has no check '{check_id}'. Prowler Hub lists "
|
||||
f"that check under provider '{hub_provider}', so retry with "
|
||||
f"provider_id='{hub_provider}'."
|
||||
)
|
||||
return when_check_belongs_here
|
||||
|
||||
|
||||
# Security Check Tools
|
||||
@@ -123,29 +233,22 @@ async def list_checks(
|
||||
if compliances:
|
||||
params["compliances"] = ",".join(compliances)
|
||||
|
||||
try:
|
||||
response = prowler_hub_client.get("/check", params=params)
|
||||
response.raise_for_status()
|
||||
checks = response.json()
|
||||
response = _get_hub_endpoint("check", params=params)
|
||||
response.raise_for_status()
|
||||
checks = parse_json_response(response)
|
||||
|
||||
# Return checks as a lightweight list
|
||||
checks_list = []
|
||||
for check in checks:
|
||||
check_data = {
|
||||
"id": check["id"],
|
||||
"provider": check["provider"],
|
||||
"title": check["title"],
|
||||
"severity": check["severity"],
|
||||
}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
# Return checks as a lightweight list
|
||||
checks_list = []
|
||||
for check in checks:
|
||||
check_data = {
|
||||
"id": check["id"],
|
||||
"provider": check["provider"],
|
||||
"title": check["title"],
|
||||
"severity": check["severity"],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
@@ -182,29 +285,22 @@ async def semantic_search_checks(
|
||||
2. Use `prowler_hub_list_checks` with filters for more targeted browsing
|
||||
3. Use `prowler_hub_get_check_details` to get complete information for a specific check
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get("/check/search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
checks = response.json()
|
||||
response = _get_hub_endpoint("check", "search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
checks = parse_json_response(response)
|
||||
|
||||
# Return checks as a lightweight list
|
||||
checks_list = []
|
||||
for check in checks:
|
||||
check_data = {
|
||||
"id": check["id"],
|
||||
"provider": check["provider"],
|
||||
"title": check["title"],
|
||||
"severity": check["severity"],
|
||||
}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
# Return checks as a lightweight list
|
||||
checks_list = []
|
||||
for check in checks:
|
||||
check_data = {
|
||||
"id": check["id"],
|
||||
"provider": check["provider"],
|
||||
"title": check["title"],
|
||||
"severity": check["severity"],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
checks_list.append(check_data)
|
||||
|
||||
return {"count": len(checks), "checks": checks_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
@@ -274,75 +370,75 @@ async def get_check_details(
|
||||
2. Use this tool with the check 'id' to get complete information including remediation guidance
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get(f"/check/{check_id}")
|
||||
response = _get_hub_endpoint("check", check_id)
|
||||
response.raise_for_status()
|
||||
check = response.json()
|
||||
|
||||
if not check:
|
||||
return {"error": f"Check '{check_id}' not found"}
|
||||
|
||||
# Build response with only non-empty fields to save tokens
|
||||
result = {}
|
||||
|
||||
# Core fields
|
||||
result["id"] = check["id"]
|
||||
if check.get("title"):
|
||||
result["title"] = check["title"]
|
||||
if check.get("description"):
|
||||
result["description"] = check["description"]
|
||||
if check.get("provider"):
|
||||
result["provider"] = check["provider"]
|
||||
if check.get("service"):
|
||||
result["service"] = check["service"]
|
||||
if check.get("severity"):
|
||||
result["severity"] = check["severity"]
|
||||
if check.get("risk"):
|
||||
result["risk"] = check["risk"]
|
||||
if check.get("resource_type"):
|
||||
result["resource_type"] = check["resource_type"]
|
||||
|
||||
# List fields
|
||||
if check.get("reference"):
|
||||
result["reference"] = check["reference"]
|
||||
if check.get("additional_urls"):
|
||||
result["additional_urls"] = check["additional_urls"]
|
||||
if check.get("services_required"):
|
||||
result["services_required"] = check["services_required"]
|
||||
if check.get("categories"):
|
||||
result["categories"] = check["categories"]
|
||||
if check.get("compliances"):
|
||||
result["compliances"] = check["compliances"]
|
||||
|
||||
# Other fields
|
||||
if check.get("notes"):
|
||||
result["notes"] = check["notes"]
|
||||
if check.get("related_url"):
|
||||
result["related_url"] = check["related_url"]
|
||||
if check.get("fixer") is not None:
|
||||
result["fixer"] = check["fixer"]
|
||||
|
||||
# Remediation - filter out empty nested values
|
||||
remediation = check.get("remediation", {})
|
||||
if remediation:
|
||||
filtered_remediation = {}
|
||||
for key, value in remediation.items():
|
||||
if value and isinstance(value, dict):
|
||||
# Filter out empty values within nested dict
|
||||
filtered_value = {k: v for k, v in value.items() if v}
|
||||
if filtered_value:
|
||||
filtered_remediation[key] = filtered_value
|
||||
elif value:
|
||||
filtered_remediation[key] = value
|
||||
if filtered_remediation:
|
||||
result["remediation"] = filtered_remediation
|
||||
|
||||
return result
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
if e.response.status_code == 404:
|
||||
# No `from`: this names the check, which the shared classifier cannot.
|
||||
raise ToolError(_CHECK_NOT_FOUND.format(check_id=check_id))
|
||||
raise
|
||||
|
||||
check = parse_json_response(response)
|
||||
|
||||
if not check:
|
||||
raise ToolError(_CHECK_NOT_FOUND.format(check_id=check_id))
|
||||
|
||||
# Build response with only non-empty fields to save tokens
|
||||
result = {}
|
||||
|
||||
# Core fields
|
||||
result["id"] = check["id"]
|
||||
if check.get("title"):
|
||||
result["title"] = check["title"]
|
||||
if check.get("description"):
|
||||
result["description"] = check["description"]
|
||||
if check.get("provider"):
|
||||
result["provider"] = check["provider"]
|
||||
if check.get("service"):
|
||||
result["service"] = check["service"]
|
||||
if check.get("severity"):
|
||||
result["severity"] = check["severity"]
|
||||
if check.get("risk"):
|
||||
result["risk"] = check["risk"]
|
||||
if check.get("resource_type"):
|
||||
result["resource_type"] = check["resource_type"]
|
||||
|
||||
# List fields
|
||||
if check.get("reference"):
|
||||
result["reference"] = check["reference"]
|
||||
if check.get("additional_urls"):
|
||||
result["additional_urls"] = check["additional_urls"]
|
||||
if check.get("services_required"):
|
||||
result["services_required"] = check["services_required"]
|
||||
if check.get("categories"):
|
||||
result["categories"] = check["categories"]
|
||||
if check.get("compliances"):
|
||||
result["compliances"] = check["compliances"]
|
||||
|
||||
# Other fields
|
||||
if check.get("notes"):
|
||||
result["notes"] = check["notes"]
|
||||
if check.get("related_url"):
|
||||
result["related_url"] = check["related_url"]
|
||||
if check.get("fixer") is not None:
|
||||
result["fixer"] = check["fixer"]
|
||||
|
||||
# Remediation - filter out empty nested values
|
||||
remediation = check.get("remediation", {})
|
||||
if remediation:
|
||||
filtered_remediation = {}
|
||||
for key, value in remediation.items():
|
||||
if value and isinstance(value, dict):
|
||||
# Filter out empty values within nested dict
|
||||
filtered_value = {k: v for k, v in value.items() if v}
|
||||
if filtered_value:
|
||||
filtered_remediation[key] = filtered_value
|
||||
elif value:
|
||||
filtered_remediation[key] = value
|
||||
if filtered_remediation:
|
||||
result["remediation"] = filtered_remediation
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
@@ -364,31 +460,38 @@ async def get_check_code(
|
||||
"content": "Python source code of the check implementation"
|
||||
}
|
||||
"""
|
||||
if provider_id and check_id:
|
||||
url = github_check_path(provider_id, check_id, ".py")
|
||||
try:
|
||||
resp = github_raw_client.get(url)
|
||||
resp.raise_for_status()
|
||||
return {
|
||||
"content": resp.text,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return {
|
||||
"error": f"Check {check_id} not found in Prowler",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": "Provider ID and check ID are required",
|
||||
}
|
||||
url = github_check_path(provider_id, check_id, ".py")
|
||||
try:
|
||||
resp = github_raw_client.get(url)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
# No `from`: this names the check and the provider that does have
|
||||
# it, neither of which the shared classifier in lib/errors.py knows.
|
||||
raise ToolError(
|
||||
_explain_missing_check_file(
|
||||
provider_id,
|
||||
check_id,
|
||||
when_check_belongs_here=(
|
||||
f"Prowler Hub lists check '{check_id}' under provider "
|
||||
f"'{provider_id}', but prowler-cloud/prowler has no source file "
|
||||
"for it on the master branch. The check may have been renamed or "
|
||||
"moved since the Hub last indexed it."
|
||||
),
|
||||
when_unverified=(
|
||||
f"Provider '{provider_id}' has no check '{check_id}' in "
|
||||
"prowler-cloud/prowler, and Prowler Hub could not be asked which "
|
||||
"provider does. Either the ID is wrong or the check belongs to "
|
||||
"another provider, prowler_hub_get_check_details reports the "
|
||||
"provider a check belongs to."
|
||||
),
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
return {
|
||||
"content": resp.text,
|
||||
}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
@@ -402,8 +505,9 @@ async def get_check_fixer(
|
||||
) -> dict:
|
||||
"""Fetch the auto-remediation (fixer) code for a Prowler security check.
|
||||
|
||||
IMPORTANT: Not all checks have fixers. A "fixer not found" response means the check
|
||||
doesn't have auto-remediation code - this is normal for many checks.
|
||||
IMPORTANT: Not all checks have fixers. A check with no auto-remediation code fails
|
||||
with a message saying so - this is normal for many checks and not a problem to
|
||||
report or retry.
|
||||
|
||||
Fixer code provides automated remediation that can fix security issues detected by checks.
|
||||
Use this to understand how to programmatically remediate findings.
|
||||
@@ -412,40 +516,37 @@ async def get_check_fixer(
|
||||
{
|
||||
"content": "Python source code of the auto-remediation implementation"
|
||||
}
|
||||
Or if no fixer exists:
|
||||
{
|
||||
"error": "Fixer not found for check {check_id}"
|
||||
}
|
||||
"""
|
||||
if provider_id and check_id:
|
||||
url = github_check_path(provider_id, check_id, "_fixer.py")
|
||||
try:
|
||||
resp = github_raw_client.get(url)
|
||||
if resp.status_code == 404:
|
||||
return {
|
||||
"error": f"Fixer not found for check {check_id}",
|
||||
}
|
||||
resp.raise_for_status()
|
||||
return {
|
||||
"content": resp.text,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return {
|
||||
"error": f"Check {check_id} not found in Prowler",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": "Provider ID and check ID are required",
|
||||
}
|
||||
url = github_check_path(provider_id, check_id, "_fixer.py")
|
||||
try:
|
||||
resp = github_raw_client.get(url)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
# "No fixer" is only one of the reasons the file is missing, and the
|
||||
# others are the caller's to fix, so they are told apart first.
|
||||
raise ToolError(
|
||||
_explain_missing_check_file(
|
||||
provider_id,
|
||||
check_id,
|
||||
when_check_belongs_here=(
|
||||
f"Check {check_id} has no auto-remediation code. Many checks do "
|
||||
"not, and that is normal."
|
||||
),
|
||||
when_unverified=(
|
||||
f"Provider '{provider_id}' has no auto-remediation code for "
|
||||
f"check '{check_id}'. Many checks have none, and that is normal, "
|
||||
f"but Prowler Hub could not be asked whether the check belongs "
|
||||
f"to '{provider_id}' at all. Confirm it with "
|
||||
"prowler_hub_get_check_details if you expected a fixer."
|
||||
),
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
return {
|
||||
"content": resp.text,
|
||||
}
|
||||
|
||||
|
||||
# Compliance Framework Tools
|
||||
@@ -492,28 +593,21 @@ async def list_compliances(
|
||||
if provider:
|
||||
params["provider"] = ",".join(provider)
|
||||
|
||||
try:
|
||||
response = prowler_hub_client.get("/compliance", params=params)
|
||||
response.raise_for_status()
|
||||
compliances = response.json()
|
||||
response = _get_hub_endpoint("compliance", params=params)
|
||||
response.raise_for_status()
|
||||
compliances = parse_json_response(response)
|
||||
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
@@ -543,28 +637,21 @@ async def semantic_search_compliances(
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get("/compliance/search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
compliances = response.json()
|
||||
response = _get_hub_endpoint("compliance", "search", params={"term": term})
|
||||
response.raise_for_status()
|
||||
compliances = parse_json_response(response)
|
||||
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
# Return compliances as a lightweight list
|
||||
compliances_list = []
|
||||
for compliance in compliances:
|
||||
compliance_data = {
|
||||
"id": compliance["id"],
|
||||
"name": compliance["name"],
|
||||
"provider": compliance["provider"],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
compliances_list.append(compliance_data)
|
||||
|
||||
return {"count": len(compliances), "compliances": compliances_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
@@ -599,63 +686,60 @@ async def get_compliance_details(
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get(f"/compliance/{compliance_id}")
|
||||
response = _get_hub_endpoint("compliance", compliance_id)
|
||||
response.raise_for_status()
|
||||
compliance = response.json()
|
||||
|
||||
if not compliance:
|
||||
return {"error": f"Compliance '{compliance_id}' not found"}
|
||||
|
||||
# Build response with only non-empty fields to save tokens
|
||||
result = {}
|
||||
|
||||
# Core fields
|
||||
result["id"] = compliance["id"]
|
||||
if compliance.get("name"):
|
||||
result["name"] = compliance["name"]
|
||||
if compliance.get("framework"):
|
||||
result["framework"] = compliance["framework"]
|
||||
if compliance.get("provider"):
|
||||
result["provider"] = compliance["provider"]
|
||||
if compliance.get("version"):
|
||||
result["version"] = compliance["version"]
|
||||
if compliance.get("description"):
|
||||
result["description"] = compliance["description"]
|
||||
|
||||
# Numeric fields
|
||||
if compliance.get("total_checks"):
|
||||
result["total_checks"] = compliance["total_checks"]
|
||||
if compliance.get("total_requirements"):
|
||||
result["total_requirements"] = compliance["total_requirements"]
|
||||
|
||||
# Requirements - filter out empty nested values
|
||||
requirements = compliance.get("requirements", [])
|
||||
if requirements:
|
||||
filtered_requirements = []
|
||||
for req in requirements:
|
||||
filtered_req = {}
|
||||
if req.get("id"):
|
||||
filtered_req["id"] = req["id"]
|
||||
if req.get("name"):
|
||||
filtered_req["name"] = req["name"]
|
||||
if req.get("description"):
|
||||
filtered_req["description"] = req["description"]
|
||||
if req.get("checks"):
|
||||
filtered_req["checks"] = req["checks"]
|
||||
if filtered_req:
|
||||
filtered_requirements.append(filtered_req)
|
||||
if filtered_requirements:
|
||||
result["requirements"] = filtered_requirements
|
||||
|
||||
return result
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return {"error": f"Compliance '{compliance_id}' not found"}
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
raise ToolError(_COMPLIANCE_NOT_FOUND.format(compliance_id=compliance_id))
|
||||
raise
|
||||
|
||||
compliance = parse_json_response(response)
|
||||
|
||||
if not compliance:
|
||||
raise ToolError(_COMPLIANCE_NOT_FOUND.format(compliance_id=compliance_id))
|
||||
|
||||
# Build response with only non-empty fields to save tokens
|
||||
result = {}
|
||||
|
||||
# Core fields
|
||||
result["id"] = compliance["id"]
|
||||
if compliance.get("name"):
|
||||
result["name"] = compliance["name"]
|
||||
if compliance.get("framework"):
|
||||
result["framework"] = compliance["framework"]
|
||||
if compliance.get("provider"):
|
||||
result["provider"] = compliance["provider"]
|
||||
if compliance.get("version"):
|
||||
result["version"] = compliance["version"]
|
||||
if compliance.get("description"):
|
||||
result["description"] = compliance["description"]
|
||||
|
||||
# Numeric fields
|
||||
if compliance.get("total_checks"):
|
||||
result["total_checks"] = compliance["total_checks"]
|
||||
if compliance.get("total_requirements"):
|
||||
result["total_requirements"] = compliance["total_requirements"]
|
||||
|
||||
# Requirements - filter out empty nested values
|
||||
requirements = compliance.get("requirements", [])
|
||||
if requirements:
|
||||
filtered_requirements = []
|
||||
for req in requirements:
|
||||
filtered_req = {}
|
||||
if req.get("id"):
|
||||
filtered_req["id"] = req["id"]
|
||||
if req.get("name"):
|
||||
filtered_req["name"] = req["name"]
|
||||
if req.get("description"):
|
||||
filtered_req["description"] = req["description"]
|
||||
if req.get("checks"):
|
||||
filtered_req["checks"] = req["checks"]
|
||||
if filtered_req:
|
||||
filtered_requirements.append(filtered_req)
|
||||
if filtered_requirements:
|
||||
result["requirements"] = filtered_requirements
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Provider Tools
|
||||
@@ -684,27 +768,20 @@ async def list_providers() -> dict:
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get("/providers")
|
||||
response.raise_for_status()
|
||||
providers = response.json()
|
||||
response = _get_hub_endpoint("providers")
|
||||
response.raise_for_status()
|
||||
providers = parse_json_response(response)
|
||||
|
||||
providers_list = []
|
||||
for provider in providers:
|
||||
providers_list.append(
|
||||
{
|
||||
"id": provider["id"],
|
||||
"name": provider.get("name", ""),
|
||||
}
|
||||
)
|
||||
providers_list = []
|
||||
for provider in providers:
|
||||
providers_list.append(
|
||||
{
|
||||
"id": provider["id"],
|
||||
"name": provider.get("name", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {"count": len(providers), "providers": providers_list}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"count": len(providers), "providers": providers_list}
|
||||
|
||||
|
||||
@hub_mcp_server.tool()
|
||||
@@ -728,24 +805,20 @@ async def get_provider_services(
|
||||
"services": ["s3", "ec2", "iam", "rds", "lambda", ...]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = prowler_hub_client.get("/providers")
|
||||
response.raise_for_status()
|
||||
providers = response.json()
|
||||
response = _get_hub_endpoint("providers")
|
||||
response.raise_for_status()
|
||||
providers = parse_json_response(response)
|
||||
|
||||
for provider in providers:
|
||||
if provider["id"] == provider_id:
|
||||
return {
|
||||
"provider_id": provider["id"],
|
||||
"provider_name": provider.get("name", ""),
|
||||
"count": len(provider.get("services", [])),
|
||||
"services": provider.get("services", []),
|
||||
}
|
||||
for provider in providers:
|
||||
if provider["id"] == provider_id:
|
||||
return {
|
||||
"provider_id": provider["id"],
|
||||
"provider_name": provider.get("name", ""),
|
||||
"count": len(provider.get("services", [])),
|
||||
"services": provider.get("services", []),
|
||||
}
|
||||
|
||||
return {"error": f"Provider '{provider_id}' not found"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {
|
||||
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
known = ", ".join(sorted(str(provider["id"]) for provider in providers))
|
||||
raise ToolError(
|
||||
f"Prowler has no provider with the ID '{provider_id}'. Available: {known}."
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ reaches a model is text this server produced.
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from pydantic import BaseModel, ValidationError
|
||||
@@ -14,7 +15,9 @@ from pydantic import BaseModel, ValidationError
|
||||
from prowler_mcp_server.lib.errors import (
|
||||
CredentialError,
|
||||
InvalidArgument,
|
||||
UpstreamInvalidResponse,
|
||||
_describe_failure,
|
||||
parse_json_response,
|
||||
)
|
||||
from prowler_mcp_server.prowler_app.utils.api_client import (
|
||||
ProwlerAPIError,
|
||||
@@ -26,6 +29,42 @@ from tests.helpers.jsonapi import jsonapi_error
|
||||
LATEST = "/api/v1/findings/latest"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- json bodies
|
||||
|
||||
|
||||
def _answer(
|
||||
body: str, *, url: str = "https://hub.prowler.com/api/check"
|
||||
) -> httpx.Response:
|
||||
"""An answer as a client would hand it back, request attached."""
|
||||
return httpx.Response(200, text=body, request=httpx.Request("GET", url))
|
||||
|
||||
|
||||
def test_a_json_body_is_returned_as_it_is():
|
||||
"""The helper only classifies the failure; the success path is untouched."""
|
||||
assert parse_json_response(_answer('{"id": "s3_bucket_public_access"}')) == {
|
||||
"id": "s3_bucket_public_access"
|
||||
}
|
||||
|
||||
|
||||
def test_a_body_that_is_not_json_names_the_host_that_answered():
|
||||
"""Which upstream is misbehaving is the one useful fact here, and the shared
|
||||
helper is reached from every sub-server that reads an upstream directly."""
|
||||
with pytest.raises(UpstreamInvalidResponse) as raised:
|
||||
parse_json_response(_answer("<html><body>502 Bad Gateway</body></html>"))
|
||||
|
||||
assert raised.value.host == "hub.prowler.com"
|
||||
assert "Bad Gateway" not in str(raised.value)
|
||||
|
||||
|
||||
def test_a_body_that_is_not_json_is_not_a_valueerror():
|
||||
"""`JSONDecodeError` is a ValueError, and callers tell an upstream fault from
|
||||
a bad argument by type alone."""
|
||||
with pytest.raises(UpstreamInvalidResponse) as raised:
|
||||
parse_json_response(_answer("not json"))
|
||||
|
||||
assert not isinstance(raised.value, ValueError)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ classification
|
||||
|
||||
|
||||
@@ -94,6 +133,29 @@ def test_an_unreadable_api_answer_is_never_called_safe_to_repeat():
|
||||
assert "check the current state" in message
|
||||
|
||||
|
||||
def test_an_unreadable_upstream_answer_is_not_blamed_on_the_arguments():
|
||||
"""A `JSONDecodeError` from an upstream and one from an argument are the same
|
||||
exception and opposite instructions."""
|
||||
message = _describe_failure(
|
||||
UpstreamInvalidResponse("200 body is not JSON", host="hub.prowler.com")
|
||||
)
|
||||
|
||||
assert "hub.prowler.com" in message
|
||||
assert "could not read as JSON" in message
|
||||
assert "changing them will not help" in message
|
||||
|
||||
|
||||
def test_an_unreadable_upstream_answer_never_quotes_the_body():
|
||||
"""The body is someone else's text, so only the host and the status leave here."""
|
||||
message = _describe_failure(
|
||||
UpstreamInvalidResponse(
|
||||
"502 body is not JSON", host="raw.githubusercontent.com"
|
||||
)
|
||||
)
|
||||
|
||||
assert "body is not JSON" not in message
|
||||
|
||||
|
||||
def test_an_argument_this_server_rejected_is_repeated_verbatim():
|
||||
"""`InvalidArgument` exists to mark a message as one we wrote."""
|
||||
message = _describe_failure(
|
||||
@@ -227,3 +289,19 @@ async def test_a_tool_specific_message_survives_masking(
|
||||
|
||||
assert result.isError is True
|
||||
assert "prowler_list_integrations" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_hub_tool_failure_says_which_host_refused_it(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""Hub failures arrive as raw httpx errors: host and status relayed, body not."""
|
||||
hub_router.add(
|
||||
"GET", "/api/check", status=503, text="<html>upstream nginx 10.1.2.3</html>"
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_hub_list_checks", {})
|
||||
|
||||
assert result.isError is True
|
||||
assert "hub.prowler.com" in result.content[0].text
|
||||
assert "10.1.2.3" not in result.content[0].text
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for the shared URL path builder.
|
||||
|
||||
The bug these pin: an identifier interpolated into a path was resolved away by
|
||||
httpx per RFC 3986, so "../" reached an endpoint no tool meant to call.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler_mcp_server.lib.urls import path_segment, url_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("s3_bucket_public_access", "s3_bucket_public_access"),
|
||||
("cis_4.0_aws", "cis_4.0_aws"),
|
||||
("../../evil", "..%2F..%2Fevil"),
|
||||
("....//evil", "....%2F%2Fevil"),
|
||||
("%2e%2e%2f", "%252e%252e%252f"),
|
||||
("..;/", "..%3B%2F"),
|
||||
("s3/../evil", "s3%2F..%2Fevil"),
|
||||
("evil?fields=all", "evil%3Ffields%3Dall"),
|
||||
("evil#frag", "evil%23frag"),
|
||||
("evil\\wrong", "evil%5Cwrong"),
|
||||
("two words", "two%20words"),
|
||||
],
|
||||
ids=[
|
||||
"plain",
|
||||
"dots-in-a-name",
|
||||
"traversal",
|
||||
"stripped-filter-bypass",
|
||||
"already-encoded",
|
||||
"path-parameter",
|
||||
"mid-path",
|
||||
"query",
|
||||
"fragment",
|
||||
"backslash",
|
||||
"space",
|
||||
],
|
||||
)
|
||||
def test_a_segment_survives_as_a_name_and_never_as_syntax(value, expected):
|
||||
"""A real ID passes through untouched; URL syntax comes back as characters."""
|
||||
assert path_segment(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [".", ".."], ids=["here", "up-one"])
|
||||
def test_a_segment_of_nothing_but_dots_is_escaped_rather_than_left_to_resolve(value):
|
||||
"""`quote` keeps a dot, so a segment of only dots would still resolve away."""
|
||||
assert path_segment(value) == value.replace(".", "%2E")
|
||||
|
||||
|
||||
def test_a_path_is_the_segments_it_was_given_and_no_others():
|
||||
"""One argument per segment, so no call site has to encode anything."""
|
||||
assert url_path("users", "../../evil", "roles") == "/users/..%2F..%2Fevil/roles"
|
||||
|
||||
|
||||
def test_a_single_segment_path_keeps_its_leading_slash():
|
||||
"""Every caller joins this onto a base URL that ends without a slash."""
|
||||
assert url_path("providers") == "/providers"
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Tests for the Prowler Hub tools.
|
||||
|
||||
The Hub sub-server uses its own httpx clients, so its failures never pass through
|
||||
the Prowler API client. They still have to arrive as tool errors rather than as a
|
||||
result object, which the protocol, the client and the model all read as a success.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
CHECKS = "/api/check"
|
||||
PROVIDERS = "/api/providers"
|
||||
COMPLIANCE = "/api/compliance"
|
||||
CHECK_ID = "s3_bucket_public_access"
|
||||
HUB_CHECK = f"{CHECKS}/{CHECK_ID}"
|
||||
|
||||
|
||||
def github_check(provider: str, suffix: str = ".py") -> str:
|
||||
"""The raw.githubusercontent path a check artifact is fetched from."""
|
||||
return (
|
||||
f"/prowler-cloud/prowler/refs/heads/master/prowler/providers/{provider}"
|
||||
f"/services/s3/{CHECK_ID}/{CHECK_ID}{suffix}"
|
||||
)
|
||||
|
||||
|
||||
GITHUB_CHECK = github_check("aws")
|
||||
GITHUB_FIXER = github_check("aws", "_fixer.py")
|
||||
|
||||
|
||||
async def test_listing_checks_returns_the_lightweight_shape(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The happy path, so the failure tests below are not the only coverage."""
|
||||
hub_router.add(
|
||||
"GET",
|
||||
CHECKS,
|
||||
json=[
|
||||
{
|
||||
"id": "s3_bucket_public_access",
|
||||
"provider": "aws",
|
||||
"title": "S3 buckets should block public access",
|
||||
"severity": "high",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_hub_list_checks", {})
|
||||
|
||||
assert result.data["count"] == 1
|
||||
assert result.data["checks"][0]["id"] == "s3_bucket_public_access"
|
||||
|
||||
|
||||
async def test_an_unknown_check_fails_and_names_the_tool_that_finds_one(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""A 404 is the caller's mistake, and the fix is a different tool."""
|
||||
hub_router.add("GET", f"{CHECKS}/nope", status=404)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_details", {"check_id": "nope"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "prowler_hub_semantic_search_checks" in result.content[0].text
|
||||
|
||||
|
||||
async def test_an_unknown_provider_fails_and_lists_the_real_ones(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The valid values are already in hand, so withholding them wastes a call."""
|
||||
hub_router.add(
|
||||
"GET",
|
||||
PROVIDERS,
|
||||
json=[{"id": "aws", "name": "Amazon Web Services", "services": ["s3"]}],
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_provider_services", {"provider_id": "alicloud"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "aws" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_check_without_a_fixer_says_that_is_normal(mcp_root_server, hub_router):
|
||||
"""Most checks have no auto-remediation, so this must not read as a defect."""
|
||||
hub_router.add("GET", GITHUB_FIXER, status=404)
|
||||
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_fixer",
|
||||
{"provider_id": "aws", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "normal" in message
|
||||
# The Hub confirmed the check is an aws check, so nothing is left to verify.
|
||||
assert "prowler_hub_get_check_details" not in message
|
||||
|
||||
|
||||
async def test_a_check_from_another_provider_names_the_provider_that_has_it(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The ID exists; only the provider is wrong. Saying otherwise sends the
|
||||
caller off to search for an ID they already hold."""
|
||||
hub_router.add("GET", github_check("azure"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "provider_id='aws'" in message
|
||||
assert "No check with the ID" not in message
|
||||
|
||||
|
||||
async def test_a_fixer_from_another_provider_is_not_reported_as_a_missing_fixer(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""'That check has no fixer' about a check the provider never had is a lie
|
||||
the caller cannot act on."""
|
||||
hub_router.add("GET", github_check("azure", "_fixer.py"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_fixer",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "provider_id='aws'" in message
|
||||
assert "auto-remediation" not in message
|
||||
|
||||
|
||||
async def test_a_check_id_that_exists_nowhere_is_still_reported_as_unknown(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The Hub not having the ID either is the one case that does justify the
|
||||
original message."""
|
||||
hub_router.add("GET", github_check("azure"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, status=404)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "No check with the ID" in result.content[0].text
|
||||
|
||||
|
||||
async def test_an_unreachable_hub_leaves_the_cause_open_rather_than_guessing(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""With nothing to distinguish the causes, naming one of them is a guess."""
|
||||
hub_router.add("GET", github_check("azure"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, status=503)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "No check with the ID" not in message
|
||||
assert "prowler_hub_get_check_details" in message
|
||||
|
||||
|
||||
async def test_a_check_code_hit_never_asks_the_hub(mcp_root_server, hub_router):
|
||||
"""The Hub lookup exists to explain a 404. On the happy path it is dead
|
||||
weight -- a second round trip for every call that already succeeded."""
|
||||
hub_router.add("GET", GITHUB_CHECK, text="class s3_bucket_public_access: ...")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "aws", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert "class s3_bucket_public_access" in result.data["content"]
|
||||
assert hub_router.paths() == [f"GET {GITHUB_CHECK}"]
|
||||
|
||||
|
||||
async def test_a_hub_outage_is_reported_rather_than_returned_as_an_empty_list(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""An empty result set and a failed request are different answers."""
|
||||
hub_router.add("GET", CHECKS, status=500, json={"detail": "boom"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_hub_list_checks", {})
|
||||
|
||||
assert result.isError is True
|
||||
assert result.structuredContent is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("check_id", "routed_as", "sent_as"),
|
||||
[
|
||||
("../../evil", f"{CHECKS}/../../evil", b"/api/check/..%2F..%2Fevil"),
|
||||
("s3/../evil", f"{CHECKS}/s3/../evil", b"/api/check/s3%2F..%2Fevil"),
|
||||
("..", f"{CHECKS}/..", b"/api/check/%2E%2E"),
|
||||
(
|
||||
"s3_x?fields=all",
|
||||
f"{CHECKS}/s3_x?fields=all",
|
||||
b"/api/check/s3_x%3Ffields%3Dall",
|
||||
),
|
||||
("s3_x#frag", f"{CHECKS}/s3_x#frag", b"/api/check/s3_x%23frag"),
|
||||
],
|
||||
ids=["traversal", "mid-path", "dot-segment", "query", "fragment"],
|
||||
)
|
||||
async def test_an_id_names_a_check_and_cannot_name_an_endpoint(
|
||||
mcp_root_server, hub_router, check_id, routed_as, sent_as
|
||||
):
|
||||
"""The bug this pins: httpx resolved "../.." away and the request left
|
||||
/api/check for another endpoint of the Hub."""
|
||||
hub_router.add("GET", routed_as, status=404)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_details", {"check_id": check_id}
|
||||
)
|
||||
|
||||
assert hub_router.requests[0].url.raw_path == sent_as
|
||||
assert result.isError is True
|
||||
assert "No check with the ID" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_compliance_id_cannot_name_an_endpoint_either(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""Every Hub path is built by the same helper, so this holds without its own
|
||||
guard."""
|
||||
hub_router.add("GET", f"{COMPLIANCE}/../../evil", status=404)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_compliance_details", {"compliance_id": "../../evil"}
|
||||
)
|
||||
|
||||
assert hub_router.requests[0].url.raw_path == b"/api/compliance/..%2F..%2Fevil"
|
||||
assert result.isError is True
|
||||
assert "No compliance framework with the ID" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_check_source_url_confines_the_provider_and_the_check_alike(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""Both halves of the GitHub raw URL come from the caller, so both are
|
||||
confined."""
|
||||
hub_router.add("GET", github_check("../../../../evil"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "../../../../evil", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert (
|
||||
hub_router.requests[0].url.raw_path
|
||||
== github_check("..%2F..%2F..%2F..%2Fevil").encode()
|
||||
)
|
||||
assert result.isError is True
|
||||
|
||||
|
||||
async def test_a_hub_body_that_is_not_json_is_not_blamed_on_the_arguments(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""An edge answering 200 with an HTML page decodes to the same
|
||||
`JSONDecodeError` a malformed argument does, and the two mean opposite
|
||||
things: nothing in this call can be corrected."""
|
||||
hub_router.add("GET", CHECKS, text="<html><body>502 Bad Gateway</body></html>")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_hub_list_checks", {})
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "hub.prowler.com" in message
|
||||
assert "could not read as JSON" in message
|
||||
assert "Bad Gateway" not in message
|
||||
assert "Send it as a real object" not in message
|
||||
|
||||
|
||||
async def test_an_unreadable_hub_answer_does_not_become_an_unknown_check(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The 404 branch is the only one that may claim the ID does not exist. A
|
||||
body that could not be read says nothing about the ID."""
|
||||
hub_router.add("GET", HUB_CHECK, text="<html>not json</html>")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_details", {"check_id": CHECK_ID}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "could not read as JSON" in message
|
||||
assert "No check with the ID" not in message
|
||||
|
||||
|
||||
async def test_an_unreadable_hub_answer_leaves_a_missing_check_file_unexplained(
|
||||
mcp_root_server, hub_router
|
||||
):
|
||||
"""The Hub is asked which provider owns the check. A body it could not read
|
||||
is no more of an answer than an outage, so it hedges the same way."""
|
||||
hub_router.add("GET", github_check("azure"), status=404)
|
||||
hub_router.add("GET", HUB_CHECK, text="<html>not json</html>")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_hub_get_check_code",
|
||||
{"provider_id": "azure", "check_id": CHECK_ID},
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
assert "No check with the ID" not in message
|
||||
assert "prowler_hub_get_check_details" in message
|
||||
Reference in New Issue
Block a user