Compare commits

..
Author SHA1 Message Date
pedrooot 77089eba57 docs(api): use mintlify for API specs 2025-10-30 18:58:05 +01:00
188 changed files with 24272 additions and 5742 deletions

No files matched your search

-2
View File
@@ -35,8 +35,6 @@ POSTGRES_DB=prowler_db
# POSTGRES_REPLICA_USER=prowler
# POSTGRES_REPLICA_PASSWORD=postgres
# POSTGRES_REPLICA_DB=prowler_db
# POSTGRES_REPLICA_MAX_ATTEMPTS=3
# POSTGRES_REPLICA_RETRY_BASE_DELAY=0.5
# Celery-Prowler task settings
TASK_RETRY_DELAY_SECONDS=0.1
@@ -43,27 +43,19 @@ runs:
if: inputs.update-ts == ''
id: slack-notification-post
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
env:
SLACK_PAYLOAD_FILE_PATH: ${{ inputs.payload-file-path }}
with:
method: chat.postMessage
token: ${{ inputs.slack-bot-token }}
payload-file-path: ${{ inputs.payload-file-path }}
payload-templated: true
errors: true
- name: Update Slack notification
if: inputs.update-ts != ''
id: slack-notification-update
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
env:
SLACK_PAYLOAD_FILE_PATH: ${{ inputs.payload-file-path }}
with:
method: chat.update
token: ${{ inputs.slack-bot-token }}
payload-file-path: ${{ inputs.payload-file-path }}
payload-templated: true
errors: true
- name: Set output
id: slack-notification
@@ -1,4 +1,4 @@
{
"channel": "${{ env.SLACK_CHANNEL_ID }}",
"text": "${{ env.STATUS_EMOJI }} ${{ env.COMPONENT }} container release ${{ env.RELEASE_TAG }} push ${{ env.STATUS_TEXT }} <${{ env.GITHUB_SERVER_URL }}/${{ env.GITHUB_REPOSITORY }}/actions/runs/${{ env.GITHUB_RUN_ID }}|View run>"
}
"channel": "$SLACK_CHANNEL_ID",
"text": "$STATUS_EMOJI $COMPONENT container release $RELEASE_TAG push $STATUS_TEXT <$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID|View run>"
}
@@ -1,4 +1,4 @@
{
"channel": "${{ env.SLACK_CHANNEL_ID }}",
"text": "${{ env.COMPONENT }} container release ${{ env.RELEASE_TAG }} push started... <${{ env.GITHUB_SERVER_URL }}/${{ env.GITHUB_REPOSITORY }}/actions/runs/${{ env.GITHUB_RUN_ID }}|View run>"
"channel": "$SLACK_CHANNEL_ID",
"text": "$COMPONENT container release $RELEASE_TAG push started... <$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID|View run>"
}
+1 -1
View File
@@ -25,7 +25,7 @@ concurrency:
cancel-in-progress: true
jobs:
api-analyze:
analyze:
name: CodeQL Security Analysis
runs-on: ubuntu-latest
timeout-minutes: 30
-1
View File
@@ -28,7 +28,6 @@ concurrency:
jobs:
sdk-code-quality:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
+1 -2
View File
@@ -31,8 +31,7 @@ concurrency:
cancel-in-progress: true
jobs:
sdk-analyze:
if: github.repository == 'prowler-cloud/prowler'
analyze:
name: CodeQL Security Analysis
runs-on: ubuntu-latest
timeout-minutes: 30
+1 -3
View File
@@ -29,7 +29,6 @@ env:
jobs:
sdk-dockerfile-lint:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -53,7 +52,6 @@ jobs:
ignore: DL3013
sdk-container-build-and-scan:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
@@ -102,7 +100,7 @@ jobs:
cache-to: type=gha,mode=max
- name: Scan SDK container with Trivy
if: steps.check-changes.outputs.any_changed == 'true'
if: github.repository == 'prowler-cloud/prowler' && steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/trivy-scan
with:
image-name: ${{ env.IMAGE_NAME }}
-1
View File
@@ -28,7 +28,6 @@ concurrency:
jobs:
sdk-security-scans:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
-1
View File
@@ -28,7 +28,6 @@ concurrency:
jobs:
sdk-tests:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
+1 -2
View File
@@ -27,8 +27,7 @@ concurrency:
cancel-in-progress: true
jobs:
ui-analyze:
if: github.repository == 'prowler-cloud/prowler'
analyze:
name: CodeQL Security Analysis
runs-on: ubuntu-latest
timeout-minutes: 30
-7
View File
@@ -10,21 +10,14 @@ All notable changes to the **Prowler API** are documented in this file.
- Support for configuring multiple LLM providers [(#8772)](https://github.com/prowler-cloud/prowler/pull/8772)
- Support C5 compliance framework for Azure provider [(#9081)](https://github.com/prowler-cloud/prowler/pull/9081)
- Support for Oracle Cloud Infrastructure (OCI) provider [(#8927)](https://github.com/prowler-cloud/prowler/pull/8927)
- Support muting findings based on simple rules with custom reason [(#9051)](https://github.com/prowler-cloud/prowler/pull/9051)
- Support C5 compliance framework for the GCP provider [(#9097)](https://github.com/prowler-cloud/prowler/pull/9097)
- Support for Amazon Bedrock and OpenAI compatible providers in Lighthouse AI [(#8957)](https://github.com/prowler-cloud/prowler/pull/8957)
---
## [1.14.1] (Prowler 5.13.1)
### Fixed
- `/api/v1/overviews/providers` collapses data by provider type so the UI receives a single aggregated record per cloud family even when multiple accounts exist [(#9053)](https://github.com/prowler-cloud/prowler/pull/9053)
- Added retry logic to database transactions to handle Aurora read replica connection failures during scale-down events [(#9064)](https://github.com/prowler-cloud/prowler/pull/9064)
- Security Hub integrations stop failing when they read relationships via the replica by allowing replica relations and saving updates through the primary [(#9080)](https://github.com/prowler-cloud/prowler/pull/9080)
---
## [1.14.0] (Prowler 5.13.0)
### Added
+2 -57
View File
@@ -1164,18 +1164,6 @@ files = [
{file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"},
]
[[package]]
name = "circuitbreaker"
version = "2.1.3"
description = "Python Circuit Breaker pattern implementation"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1"},
{file = "circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084"},
]
[[package]]
name = "click"
version = "8.2.1"
@@ -4058,29 +4046,6 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "oci"
version = "2.160.3"
description = "Oracle Cloud Infrastructure Python SDK"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "oci-2.160.3-py3-none-any.whl", hash = "sha256:858bff3e697098bdda44833d2476bfb4632126f0182178e7dbde4dbd156d71f0"},
{file = "oci-2.160.3.tar.gz", hash = "sha256:57514889be3b713a8385d86e3ba8a33cf46e3563c2a7e29a93027fb30b8a2537"},
]
[package.dependencies]
certifi = "*"
circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""}
cryptography = ">=3.2.1,<46.0.0"
pyOpenSSL = ">=17.5.0,<25.0.0"
python-dateutil = ">=2.5.3,<3.0.0"
pytz = ">=2016.10"
[package.extras]
adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""]
[[package]]
name = "openai"
version = "1.101.0"
@@ -4615,7 +4580,7 @@ files = [
[[package]]
name = "prowler"
version = "5.14.0"
version = "5.13.0"
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
optional = false
python-versions = ">3.9.1,<3.13"
@@ -4669,7 +4634,6 @@ markdown = "3.9.0"
microsoft-kiota-abstractions = "1.9.2"
msgraph-sdk = "1.23.0"
numpy = "2.0.2"
oci = "2.160.3"
pandas = "2.2.3"
py-iam-expand = "0.1.0"
py-ocsf-models = "0.5.0"
@@ -4687,7 +4651,7 @@ tzlocal = "5.3.1"
type = "git"
url = "https://github.com/prowler-cloud/prowler.git"
reference = "master"
resolved_reference = "a41f8dcb18190e44cebed11d5cea8850bd3ba80e"
resolved_reference = "a52697bfdfee83d14a49c11dcbe96888b5cd767e"
[[package]]
name = "psutil"
@@ -5172,25 +5136,6 @@ cffi = ">=1.4.1"
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
[[package]]
name = "pyopenssl"
version = "24.3.0"
description = "Python wrapper module around the OpenSSL library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a"},
{file = "pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36"},
]
[package.dependencies]
cryptography = ">=41.0.5,<45"
[package.extras]
docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"]
test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"]
[[package]]
name = "pyparsing"
version = "3.2.3"
+25 -3
View File
@@ -9,6 +9,25 @@ PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE = {}
PROWLER_CHECKS = {}
AVAILABLE_COMPLIANCE_FRAMEWORKS = {}
# Map API provider names to Prowler directory names
# This is needed because the OCI provider directory is 'oraclecloud' but the provider type is 'oci'
PROVIDER_NAME_MAPPING = {
"oci": "oraclecloud",
}
def get_prowler_provider_name(provider_type: str) -> str:
"""
Map API provider type to Prowler provider directory name.
Args:
provider_type: The provider type from the API (e.g., 'oci', 'aws', 'azure')
Returns:
The provider name used in Prowler's directory structure (e.g., 'oraclecloud', 'aws', 'azure')
"""
return PROVIDER_NAME_MAPPING.get(provider_type, provider_type)
def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]:
"""
@@ -28,8 +47,9 @@ def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[s
"""
global AVAILABLE_COMPLIANCE_FRAMEWORKS
if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS:
prowler_provider_name = get_prowler_provider_name(provider_type)
AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = (
get_available_compliance_frameworks(provider_type)
get_available_compliance_frameworks(prowler_provider_name)
)
return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type]
@@ -49,7 +69,8 @@ def get_prowler_provider_checks(provider_type: Provider.ProviderChoices):
Returns:
Iterable[str]: An iterable of check IDs associated with the specified provider type.
"""
return CheckMetadata.get_bulk(provider_type).keys()
prowler_provider_name = get_prowler_provider_name(provider_type)
return CheckMetadata.get_bulk(prowler_provider_name).keys()
def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) -> dict:
@@ -67,7 +88,8 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) ->
dict: A dictionary mapping compliance framework names to their respective
Compliance objects for the specified provider.
"""
return Compliance.get_bulk(provider_type)
prowler_provider_name = get_prowler_provider_name(provider_type)
return Compliance.get_bulk(prowler_provider_name)
def load_prowler_compliance():
+19 -66
View File
@@ -1,35 +1,18 @@
import re
import secrets
import time
import uuid
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from celery.utils.log import get_task_logger
from config.env import env
from django.conf import settings
from django.contrib.auth.models import BaseUserManager
from django.db import (
DEFAULT_DB_ALIAS,
OperationalError,
connection,
connections,
models,
transaction,
)
from django.db import DEFAULT_DB_ALIAS, connection, connections, models, transaction
from django_celery_beat.models import PeriodicTask
from psycopg2 import connect as psycopg2_connect
from psycopg2.extensions import AsIs, new_type, register_adapter, register_type
from rest_framework_json_api.serializers import ValidationError
from api.db_router import (
READ_REPLICA_ALIAS,
get_read_db_alias,
reset_read_db_alias,
set_read_db_alias,
)
logger = get_task_logger(__name__)
from api.db_router import get_read_db_alias, reset_read_db_alias, set_read_db_alias
DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test"
DB_PASSWORD = (
@@ -45,9 +28,6 @@ TASK_RUNNER_DB_TABLE = "django_celery_results_taskresult"
POSTGRES_TENANT_VAR = "api.tenant_id"
POSTGRES_USER_VAR = "api.user_id"
REPLICA_MAX_ATTEMPTS = env.int("POSTGRES_REPLICA_MAX_ATTEMPTS", default=3)
REPLICA_RETRY_BASE_DELAY = env.float("POSTGRES_REPLICA_RETRY_BASE_DELAY", default=0.5)
SET_CONFIG_QUERY = "SELECT set_config(%s, %s::text, TRUE);"
@@ -91,51 +71,24 @@ def rls_transaction(
if db_alias not in connections:
db_alias = DEFAULT_DB_ALIAS
alias = db_alias
is_replica = READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS
max_attempts = REPLICA_MAX_ATTEMPTS if is_replica else 1
router_token = None
try:
if db_alias != DEFAULT_DB_ALIAS:
router_token = set_read_db_alias(db_alias)
for attempt in range(1, max_attempts + 1):
router_token = None
# On final attempt, fallback to primary
if attempt == max_attempts and is_replica:
logger.warning(
f"RLS transaction failed after {attempt - 1} attempts on replica, "
f"falling back to primary DB"
)
alias = DEFAULT_DB_ALIAS
conn = connections[alias]
try:
if alias != DEFAULT_DB_ALIAS:
router_token = set_read_db_alias(alias)
with transaction.atomic(using=alias):
with conn.cursor() as cursor:
try:
# just in case the value is a UUID object
uuid.UUID(str(value))
except ValueError:
raise ValidationError("Must be a valid UUID")
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
yield cursor
return
except OperationalError as e:
# If on primary or max attempts reached, raise
if not is_replica or attempt == max_attempts:
raise
# Retry with exponential backoff
delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
logger.info(
f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), "
f"retrying in {delay}s. Error: {e}"
)
time.sleep(delay)
finally:
if router_token is not None:
reset_read_db_alias(router_token)
with transaction.atomic(using=db_alias):
conn = connections[db_alias]
with conn.cursor() as cursor:
try:
# just in case the value is a UUID object
uuid.UUID(str(value))
except ValueError:
raise ValidationError("Must be a valid UUID")
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
yield cursor
finally:
if router_token is not None:
reset_read_db_alias(router_token)
class CustomUserManager(BaseUserManager):
-18
View File
@@ -30,7 +30,6 @@ from api.models import (
LighthouseProviderConfiguration,
LighthouseProviderModels,
Membership,
MuteRule,
OverviewStatusChoices,
PermissionChoices,
Processor,
@@ -981,20 +980,3 @@ class LighthouseProviderModelsFilter(FilterSet):
fields = {
"model_id": ["exact", "icontains", "in"],
}
class MuteRuleFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
created_by = UUIDFilter(field_name="created_by__id", lookup_expr="exact")
class Meta:
model = MuteRule
fields = {
"id": ["exact", "in"],
"name": ["exact", "icontains"],
"reason": ["icontains"],
"enabled": ["exact"],
"inserted_at": ["gte", "lte"],
"updated_at": ["gte", "lte"],
}
@@ -1,117 +0,0 @@
# Generated by Django 5.1.13 on 2025-10-22 11:56
import uuid
import django.contrib.postgres.fields
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import api.rls
class Migration(migrations.Migration):
dependencies = [
("api", "0051_oraclecloud_provider"),
]
operations = [
migrations.CreateModel(
name="MuteRule",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"name",
models.CharField(
help_text="Human-readable name for this rule",
max_length=100,
validators=[django.core.validators.MinLengthValidator(3)],
),
),
(
"reason",
models.TextField(
help_text="Reason for muting",
max_length=500,
validators=[django.core.validators.MinLengthValidator(3)],
),
),
(
"enabled",
models.BooleanField(
default=True, help_text="Whether this rule is currently enabled"
),
),
(
"finding_uids",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=255),
help_text="List of finding UIDs to mute",
size=None,
),
),
],
options={
"db_table": "mute_rules",
"abstract": False,
},
),
migrations.AddField(
model_name="finding",
name="muted_at",
field=models.DateTimeField(
blank=True, help_text="Timestamp when this finding was muted", null=True
),
),
migrations.AlterField(
model_name="tenantapikey",
name="name",
field=models.CharField(
max_length=100,
validators=[django.core.validators.MinLengthValidator(3)],
),
),
migrations.AddField(
model_name="muterule",
name="created_by",
field=models.ForeignKey(
help_text="User who created this rule",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_mute_rules",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="muterule",
name="tenant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
migrations.AddConstraint(
model_name="muterule",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_muterule",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="muterule",
constraint=models.UniqueConstraint(
fields=("tenant_id", "name"), name="unique_mute_rule_name_per_tenant"
),
),
]
@@ -1,25 +0,0 @@
# Generated by Django 5.1.12 on 2025-10-14 11:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0052_mute_rules"),
]
operations = [
migrations.AlterField(
model_name="lighthouseproviderconfiguration",
name="provider_type",
field=models.CharField(
choices=[
("openai", "OpenAI"),
("bedrock", "AWS Bedrock"),
("openai_compatible", "OpenAI Compatible"),
],
help_text="LLM provider name",
max_length=50,
),
)
]
-58
View File
@@ -823,9 +823,6 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
muted_reason = models.TextField(
blank=True, null=True, validators=[MinLengthValidator(3)], max_length=500
)
muted_at = models.DateTimeField(
null=True, blank=True, help_text="Timestamp when this finding was muted"
)
compliance = models.JSONField(default=dict, null=True, blank=True)
# Denormalize resource data for performance
@@ -1938,59 +1935,6 @@ class LighthouseConfiguration(RowLevelSecurityProtectedModel):
resource_name = "lighthouse-configurations"
class MuteRule(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
# Rule metadata
name = models.CharField(
max_length=100,
validators=[MinLengthValidator(3)],
help_text="Human-readable name for this rule",
)
reason = models.TextField(
validators=[MinLengthValidator(3)],
max_length=500,
help_text="Reason for muting",
)
enabled = models.BooleanField(
default=True, help_text="Whether this rule is currently enabled"
)
# Audit fields
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name="created_mute_rules",
help_text="User who created this rule",
)
# Rule criteria - array of finding UIDs
finding_uids = ArrayField(
models.CharField(max_length=255), help_text="List of finding UIDs to mute"
)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "mute_rules"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
models.UniqueConstraint(
fields=("tenant_id", "name"),
name="unique_mute_rule_name_per_tenant",
),
]
class JSONAPIMeta:
resource_name = "mute-rules"
class Processor(RowLevelSecurityProtectedModel):
class ProcessorChoices(models.TextChoices):
MUTELIST = "mutelist", _("Mutelist")
@@ -2039,8 +1983,6 @@ class LighthouseProviderConfiguration(RowLevelSecurityProtectedModel):
class LLMProviderChoices(models.TextChoices):
OPENAI = "openai", _("OpenAI")
BEDROCK = "bedrock", _("AWS Bedrock")
OPENAI_COMPATIBLE = "openai_compatible", _("OpenAI Compatible")
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
File diff suppressed because it is too large. Load diff
@@ -1,39 +0,0 @@
"""Tests for rls_transaction retry and fallback logic."""
import pytest
from django.db import DEFAULT_DB_ALIAS
from rest_framework_json_api.serializers import ValidationError
from api.db_utils import rls_transaction
@pytest.mark.django_db
class TestRLSTransaction:
"""Simple integration tests for rls_transaction using real DB."""
@pytest.fixture
def tenant(self, tenants_fixture):
return tenants_fixture[0]
def test_success_on_primary(self, tenant):
"""Basic: transaction succeeds on primary database."""
with rls_transaction(str(tenant.id), using=DEFAULT_DB_ALIAS) as cursor:
cursor.execute("SELECT 1")
result = cursor.fetchone()
assert result == (1,)
def test_invalid_uuid_raises_validation_error(self):
"""Invalid UUID raises ValidationError before DB operations."""
with pytest.raises(ValidationError, match="Must be a valid UUID"):
with rls_transaction("not-a-uuid", using=DEFAULT_DB_ALIAS):
pass
def test_custom_parameter_name(self, tenant):
"""Test custom RLS parameter name."""
custom_param = "api.custom_id"
with rls_transaction(
str(tenant.id), parameter=custom_param, using=DEFAULT_DB_ALIAS
) as cursor:
cursor.execute("SELECT current_setting(%s, true)", [custom_param])
result = cursor.fetchone()
assert result == (str(tenant.id),)
+1 -510
View File
@@ -1,15 +1,12 @@
from datetime import datetime, timezone
from enum import Enum
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from django.conf import settings
from django.db import DEFAULT_DB_ALIAS, OperationalError
from freezegun import freeze_time
from rest_framework_json_api.serializers import ValidationError
from api.db_utils import (
POSTGRES_TENANT_VAR,
_should_create_index_on_partition,
batch_delete,
create_objects_in_batches,
@@ -17,22 +14,11 @@ from api.db_utils import (
generate_api_key_prefix,
generate_random_token,
one_week_from_now,
rls_transaction,
update_objects_in_batches,
)
from api.models import Provider
@pytest.fixture
def enable_read_replica():
"""
Fixture to enable READ_REPLICA_ALIAS for tests that need replica functionality.
This avoids polluting the global test configuration.
"""
with patch("api.db_utils.READ_REPLICA_ALIAS", "replica"):
yield "replica"
class TestEnumToChoices:
def test_enum_to_choices_simple(self):
class Color(Enum):
@@ -353,498 +339,3 @@ class TestGenerateApiKeyPrefix:
prefix = generate_api_key_prefix()
random_part = prefix[3:] # Strip 'pk_'
assert all(char in allowed_chars for char in random_part)
@pytest.mark.django_db
class TestRlsTransaction:
def test_rls_transaction_valid_uuid_string(self, tenants_fixture):
"""Test rls_transaction with valid UUID string."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with rls_transaction(tenant_id) as cursor:
assert cursor is not None
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == tenant_id
def test_rls_transaction_valid_uuid_object(self, tenants_fixture):
"""Test rls_transaction with UUID object."""
tenant = tenants_fixture[0]
with rls_transaction(tenant.id) as cursor:
assert cursor is not None
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == str(tenant.id)
def test_rls_transaction_invalid_uuid_raises_validation_error(self):
"""Test rls_transaction raises ValidationError for invalid UUID."""
invalid_uuid = "not-a-valid-uuid"
with pytest.raises(ValidationError, match="Must be a valid UUID"):
with rls_transaction(invalid_uuid):
pass
def test_rls_transaction_uses_default_database_when_no_alias(self, tenants_fixture):
"""Test rls_transaction uses DEFAULT_DB_ALIAS when no alias specified."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with rls_transaction(tenant_id):
pass
mock_connections.__getitem__.assert_called_with(DEFAULT_DB_ALIAS)
def test_rls_transaction_uses_specified_alias(self, tenants_fixture):
"""Test rls_transaction uses specified database alias via using parameter."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
custom_alias = "custom_db"
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias:
mock_set_alias.return_value = "test_token"
with rls_transaction(tenant_id, using=custom_alias):
pass
mock_connections.__getitem__.assert_called_with(custom_alias)
mock_set_alias.assert_called_once_with(custom_alias)
mock_reset_alias.assert_called_once_with("test_token")
def test_rls_transaction_uses_read_replica_from_router(
self, tenants_fixture, enable_read_replica
):
"""Test rls_transaction uses read replica alias from router."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch(
"api.db_utils.reset_read_db_alias"
) as mock_reset_alias:
mock_set_alias.return_value = "test_token"
with rls_transaction(tenant_id):
pass
mock_connections.__getitem__.assert_called()
mock_set_alias.assert_called_once()
mock_reset_alias.assert_called_once()
def test_rls_transaction_fallback_to_default_when_alias_not_in_connections(
self, tenants_fixture
):
"""Test rls_transaction falls back to DEFAULT_DB_ALIAS when alias not in connections."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
invalid_alias = "nonexistent_db"
with patch("api.db_utils.get_read_db_alias", return_value=invalid_alias):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
def contains_check(alias):
return alias == DEFAULT_DB_ALIAS
mock_connections.__contains__.side_effect = contains_check
mock_connections.__getitem__.return_value = mock_conn
with patch("api.db_utils.transaction.atomic"):
with rls_transaction(tenant_id):
pass
mock_connections.__getitem__.assert_called_with(DEFAULT_DB_ALIAS)
def test_rls_transaction_successful_execution_on_replica_no_retries(
self, tenants_fixture, enable_read_replica
):
"""Test successful execution on replica without retries."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias", return_value="token"):
with patch("api.db_utils.reset_read_db_alias"):
with rls_transaction(tenant_id):
pass
assert mock_cursor.execute.call_count == 1
def test_rls_transaction_retry_with_exponential_backoff_on_operational_error(
self, tenants_fixture, enable_read_replica
):
"""Test retry with exponential backoff on OperationalError on replica."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
call_count = 0
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise OperationalError("Connection error")
return MagicMock(
__enter__=MagicMock(return_value=None),
__exit__=MagicMock(return_value=False),
)
with patch(
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
):
with patch("api.db_utils.time.sleep") as mock_sleep:
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with patch("api.db_utils.logger") as mock_logger:
with rls_transaction(tenant_id):
pass
assert mock_sleep.call_count == 2
mock_sleep.assert_any_call(0.5)
mock_sleep.assert_any_call(1.0)
assert mock_logger.info.call_count == 2
def test_rls_transaction_max_three_attempts_for_replica(
self, tenants_fixture, enable_read_replica
):
"""Test maximum 3 attempts for replica database."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = OperationalError("Persistent error")
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(OperationalError):
with rls_transaction(tenant_id):
pass
assert mock_atomic.call_count == 3
def test_rls_transaction_only_one_attempt_for_primary(self, tenants_fixture):
"""Test only 1 attempt for primary database."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = OperationalError("Primary error")
with pytest.raises(OperationalError):
with rls_transaction(tenant_id):
pass
assert mock_atomic.call_count == 1
def test_rls_transaction_fallback_to_primary_after_max_attempts(
self, tenants_fixture, enable_read_replica
):
"""Test fallback to primary DB after max attempts on replica."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
call_count = 0
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise OperationalError("Replica error")
return MagicMock(
__enter__=MagicMock(return_value=None),
__exit__=MagicMock(return_value=False),
)
with patch(
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
):
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with patch("api.db_utils.logger") as mock_logger:
with rls_transaction(tenant_id):
pass
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
assert "falling back to primary DB" in warning_msg
def test_rls_transaction_logger_warning_on_fallback(
self, tenants_fixture, enable_read_replica
):
"""Test logger warnings are emitted on fallback to primary."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
call_count = 0
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise OperationalError("Replica error")
return MagicMock(
__enter__=MagicMock(return_value=None),
__exit__=MagicMock(return_value=False),
)
with patch(
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
):
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with patch("api.db_utils.logger") as mock_logger:
with rls_transaction(tenant_id):
pass
assert mock_logger.info.call_count == 2
assert mock_logger.warning.call_count == 1
def test_rls_transaction_operational_error_raised_immediately_on_primary(
self, tenants_fixture
):
"""Test OperationalError raised immediately on primary without retry."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = OperationalError("Primary error")
with patch("api.db_utils.time.sleep") as mock_sleep:
with pytest.raises(OperationalError):
with rls_transaction(tenant_id):
pass
mock_sleep.assert_not_called()
def test_rls_transaction_operational_error_raised_after_max_attempts(
self, tenants_fixture, enable_read_replica
):
"""Test OperationalError raised after max attempts on replica."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = OperationalError(
"Persistent replica error"
)
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(OperationalError):
with rls_transaction(tenant_id):
pass
def test_rls_transaction_router_token_set_for_non_default_alias(
self, tenants_fixture
):
"""Test router token is set when using non-default alias."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
custom_alias = "custom_db"
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias:
mock_set_alias.return_value = "test_token"
with rls_transaction(tenant_id, using=custom_alias):
pass
mock_set_alias.assert_called_once_with(custom_alias)
mock_reset_alias.assert_called_once_with("test_token")
def test_rls_transaction_router_token_reset_in_finally_block(self, tenants_fixture):
"""Test router token is reset in finally block even on error."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
custom_alias = "custom_db"
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = Exception("Unexpected error")
with patch("api.db_utils.set_read_db_alias", return_value="test_token"):
with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias:
with pytest.raises(Exception):
with rls_transaction(tenant_id, using=custom_alias):
pass
mock_reset_alias.assert_called_once_with("test_token")
def test_rls_transaction_router_token_not_set_for_default_alias(
self, tenants_fixture
):
"""Test router token is not set when using default alias."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch(
"api.db_utils.reset_read_db_alias"
) as mock_reset_alias:
with rls_transaction(tenant_id):
pass
mock_set_alias.assert_not_called()
mock_reset_alias.assert_not_called()
def test_rls_transaction_set_config_query_executed_with_correct_params(
self, tenants_fixture
):
"""Test SET_CONFIG_QUERY executed with correct parameters."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with rls_transaction(tenant_id) as cursor:
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == tenant_id
def test_rls_transaction_custom_parameter(self, tenants_fixture):
"""Test rls_transaction with custom parameter name."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
custom_param = "api.user_id"
with rls_transaction(tenant_id, parameter=custom_param) as cursor:
cursor.execute("SELECT current_setting(%s)", [custom_param])
result = cursor.fetchone()
assert result[0] == tenant_id
def test_rls_transaction_cursor_yielded_correctly(self, tenants_fixture):
"""Test cursor is yielded correctly."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with rls_transaction(tenant_id) as cursor:
assert cursor is not None
cursor.execute("SELECT 1")
result = cursor.fetchone()
assert result[0] == 1
-934
View File
@@ -34,7 +34,6 @@ from rest_framework.response import Response
from api.compliance import get_compliance_frameworks
from api.db_router import MainRouter
from api.models import (
Finding,
Integration,
Invitation,
LighthouseProviderConfiguration,
@@ -62,8 +61,6 @@ from api.models import (
from api.rls import Tenant
from api.v1.serializers import TokenSerializer
from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
class TestViewSet:
@@ -9396,934 +9393,3 @@ class TestLighthouseProviderConfigViewSet:
# Unrelated entries should remain untouched
assert cfg.default_models.get("other") == "model-x"
@pytest.mark.django_db
class TestMuteRuleViewSet:
"""Tests for MuteRule endpoints."""
def test_mute_rules_list(self, authenticated_client, mute_rules_fixture):
"""Test listing all mute rules for the tenant."""
response = authenticated_client.get(reverse("mute-rule-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == len(mute_rules_fixture)
def test_mute_rules_list_empty(self, authenticated_client, tenants_fixture):
"""Test listing mute rules when none exist returns empty list."""
response = authenticated_client.get(reverse("mute-rule-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 0
assert isinstance(data, list)
def test_mute_rules_list_default_ordering(
self, authenticated_client, mute_rules_fixture
):
"""Test that mute rules are ordered by -inserted_at by default."""
response = authenticated_client.get(reverse("mute-rule-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
if len(data) >= 2:
first_date = data[0]["attributes"]["inserted_at"]
second_date = data[1]["attributes"]["inserted_at"]
assert first_date >= second_date
def test_mute_rules_retrieve(self, authenticated_client, mute_rules_fixture):
"""Test retrieving a single mute rule by ID."""
mute_rule = mute_rules_fixture[0]
response = authenticated_client.get(
reverse("mute-rule-detail", kwargs={"pk": mute_rule.id})
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert data["id"] == str(mute_rule.id)
assert data["attributes"]["name"] == mute_rule.name
assert data["attributes"]["reason"] == mute_rule.reason
assert data["attributes"]["enabled"] == mute_rule.enabled
assert "finding_uids" in data["attributes"]
assert "inserted_at" in data["attributes"]
assert "updated_at" in data["attributes"]
def test_mute_rules_retrieve_invalid(self, authenticated_client):
"""Test retrieving non-existent mute rule returns 404."""
response = authenticated_client.get(
reverse(
"mute-rule-detail",
kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"filter_name, filter_value, expected_count",
(
[
("name", "Test Rule 1", 1),
("name.icontains", "rule", 2),
("reason.icontains", "security", 1),
("enabled", True, 1),
("enabled", False, 1),
]
),
)
def test_mute_rule_filters(
self,
authenticated_client,
mute_rules_fixture,
filter_name,
filter_value,
expected_count,
):
"""Test filtering mute rules by various fields."""
filters = {f"filter[{filter_name}]": filter_value}
response = authenticated_client.get(reverse("mute-rule-list"), filters)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == expected_count
def test_mute_rule_filter_by_created_by(
self, authenticated_client, mute_rules_fixture, create_test_user
):
"""Test filtering mute rules by creator."""
response = authenticated_client.get(
reverse("mute-rule-list"),
{"filter[created_by]": create_test_user.id},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 2
def test_mute_rule_search(self, authenticated_client, mute_rules_fixture):
"""Test searching mute rules by name and reason."""
response = authenticated_client.get(
reverse("mute-rule-list"), {"filter[search]": "Rule 1"}
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
@pytest.mark.parametrize(
"sort_field, first_index",
(
[
("name", 0),
("-name", 1),
("inserted_at", 0),
("-inserted_at", 1),
]
),
)
def test_mute_rule_ordering(
self, authenticated_client, mute_rules_fixture, sort_field, first_index
):
"""Test ordering mute rules by various fields."""
response = authenticated_client.get(
reverse("mute-rule-list"), {"sort": sort_field}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 2
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
def test_mute_rules_create_valid(
self,
mock_task,
authenticated_client,
findings_fixture,
create_test_user,
):
"""Test creating a valid mute rule."""
finding_ids = [str(findings_fixture[0].id)]
data = {
"data": {
"type": "mute-rules",
"attributes": {
"name": "New Mute Rule",
"reason": "Security exception approved",
"finding_ids": finding_ids,
},
}
}
response = authenticated_client.post(
reverse("mute-rule-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
# Verify response contains the created mute rule
response_data = response.json()["data"]
assert response_data["type"] == "mute-rules"
assert response_data["attributes"]["name"] == "New Mute Rule"
assert response_data["attributes"]["reason"] == "Security exception approved"
# Verify the finding was immediately muted
from api.models import Finding
finding = Finding.objects.get(id=findings_fixture[0].id)
assert finding.muted is True
assert finding.muted_at is not None
assert finding.muted_reason == "Security exception approved"
# Verify background task was called
mock_task.assert_called_once()
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
def test_mute_rules_create_converts_finding_ids_to_uids(
self,
mock_task,
authenticated_client,
findings_fixture,
):
"""Test that finding_ids are converted to finding UIDs."""
finding_ids = [str(findings_fixture[0].id), str(findings_fixture[1].id)]
data = {
"data": {
"type": "mute-rules",
"attributes": {
"name": "UID Conversion Test",
"reason": "Testing UID conversion",
"finding_ids": finding_ids,
},
}
}
response = authenticated_client.post(
reverse("mute-rule-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
# Verify finding_uids contains the UIDs, not IDs
from api.models import MuteRule
mute_rule = MuteRule.objects.get(name="UID Conversion Test")
expected_uids = [
findings_fixture[0].uid,
findings_fixture[1].uid,
]
assert set(mute_rule.finding_uids) == set(expected_uids)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
def test_mute_rules_deduplicates_uids(
self,
mock_task,
authenticated_client,
tenants_fixture,
providers_fixture,
scans_fixture,
):
"""Test that multiple findings with same UID result in only one UID in the rule."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
shared_uid = "prowler-aws-dedupe-test-001"
finding1 = Finding.objects.create(
tenant=tenant,
uid=shared_uid,
scan=scan,
status=Status.FAIL,
status_extended="test",
severity=Severity.high,
impact=Severity.high,
check_id="test_check",
check_metadata={"CheckId": "test_check"},
raw_result={},
)
finding2 = Finding.objects.create(
tenant=tenant,
uid=shared_uid,
scan=scan,
status=Status.FAIL,
status_extended="test",
severity=Severity.high,
impact=Severity.high,
check_id="test_check",
check_metadata={"CheckId": "test_check"},
raw_result={},
)
finding_ids = [str(finding1.id), str(finding2.id)]
data = {
"data": {
"type": "mute-rules",
"attributes": {
"name": "Dedupe Test Rule",
"reason": "Testing UID deduplication",
"finding_ids": finding_ids,
},
}
}
response = authenticated_client.post(
reverse("mute-rule-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
from api.models import MuteRule
mute_rule = MuteRule.objects.get(name="Dedupe Test Rule")
assert len(mute_rule.finding_uids) == 1
assert mute_rule.finding_uids[0] == shared_uid
finding1.refresh_from_db()
finding2.refresh_from_db()
assert finding1.muted is True
assert finding2.muted is True
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
def test_mute_rules_create_overlap_detection_active(
self,
mock_task,
authenticated_client,
mute_rules_fixture,
findings_fixture,
):
"""Test that creating a rule with overlapping UIDs in active rule fails."""
# mute_rules_fixture[0] is active and has findings_fixture[0] UID
finding_ids = [str(findings_fixture[0].id)]
data = {
"data": {
"type": "mute-rules",
"attributes": {
"name": "Overlapping Rule",
"reason": "This should fail",
"finding_ids": finding_ids,
},
}
}
response = authenticated_client.post(
reverse("mute-rule-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_409_CONFLICT
assert "errors" in response.json()
error_detail = response.json()["errors"][0]["detail"]
assert (
"already muted" in error_detail.lower() or "overlap" in error_detail.lower()
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
def test_mute_rules_create_no_overlap_with_inactive(
self,
mock_task,
authenticated_client,
mute_rules_fixture,
findings_fixture,
):
"""Test that disabled rules don't prevent new rules with same UIDs."""
# mute_rules_fixture[1] is disabled
# Disable the enabled rule first
mute_rules_fixture[0].enabled = False
mute_rules_fixture[0].save()
finding_ids = [str(findings_fixture[0].id)]
data = {
"data": {
"type": "mute-rules",
"attributes": {
"name": "Non-overlapping Rule",
"reason": "Inactive rules don't block",
"finding_ids": finding_ids,
},
}
}
response = authenticated_client.post(
reverse("mute-rule-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
def test_mute_rules_create_invalid_empty_finding_ids(self, authenticated_client):
"""Test creating mute rule with empty finding_ids fails."""
data = {
"data": {
"type": "mute-rules",
"attributes": {
"name": "Valid",
"reason": "Valid",
"finding_ids": [],
},
}
}
response = authenticated_client.post(
reverse("mute-rule-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "errors" in response.json()
assert (
response.json()["errors"][0]["source"]["pointer"]
== "/data/attributes/finding_ids"
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
def test_mute_rules_create_invalid_finding_ids(
self, mock_task, authenticated_client
):
"""Test creating mute rule with non-existent finding IDs fails."""
data = {
"data": {
"type": "mute-rules",
"attributes": {
"name": "Invalid Findings",
"reason": "This should fail",
"finding_ids": ["f498b103-c760-4785-9a3e-e23fafbb7b02"],
},
}
}
response = authenticated_client.post(
reverse("mute-rule-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "errors" in response.json()
def test_mute_rules_create_duplicate_name(
self, authenticated_client, mute_rules_fixture
):
"""Test creating a mute rule with duplicate name fails."""
existing_name = mute_rules_fixture[0].name
data = {
"data": {
"type": "mute-rules",
"attributes": {
"name": existing_name,
"reason": "Duplicate name test",
"finding_ids": ["f498b103-c760-4785-9a3e-e23fafbb7b02"],
},
}
}
response = authenticated_client.post(
reverse("mute-rule-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "errors" in response.json()
def test_mute_rules_update_name(self, authenticated_client, mute_rules_fixture):
"""Test updating mute rule name."""
mute_rule = mute_rules_fixture[0]
data = {
"data": {
"type": "mute-rules",
"id": str(mute_rule.id),
"attributes": {
"name": "Updated Name",
},
}
}
response = authenticated_client.patch(
reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
response_data = response.json()["data"]
assert response_data["attributes"]["name"] == "Updated Name"
# Verify database was updated
mute_rule.refresh_from_db()
assert mute_rule.name == "Updated Name"
def test_mute_rules_update_reason(self, authenticated_client, mute_rules_fixture):
"""Test updating mute rule reason."""
mute_rule = mute_rules_fixture[0]
data = {
"data": {
"type": "mute-rules",
"id": str(mute_rule.id),
"attributes": {
"reason": "Updated reason for muting",
},
}
}
response = authenticated_client.patch(
reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
response_data = response.json()["data"]
assert response_data["attributes"]["reason"] == "Updated reason for muting"
mute_rule.refresh_from_db()
assert mute_rule.reason == "Updated reason for muting"
def test_mute_rules_update_enabled(self, authenticated_client, mute_rules_fixture):
"""Test disabling a mute rule."""
mute_rule = mute_rules_fixture[0]
assert mute_rule.enabled is True
data = {
"data": {
"type": "mute-rules",
"id": str(mute_rule.id),
"attributes": {
"enabled": False,
},
}
}
response = authenticated_client.patch(
reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
response_data = response.json()["data"]
assert response_data["attributes"]["enabled"] is False
mute_rule.refresh_from_db()
assert mute_rule.enabled is False
def test_mute_rules_update_duplicate_name(
self, authenticated_client, mute_rules_fixture
):
"""Test updating mute rule with duplicate name fails."""
first_rule = mute_rules_fixture[0]
second_rule = mute_rules_fixture[1]
data = {
"data": {
"type": "mute-rules",
"id": str(second_rule.id),
"attributes": {
"name": first_rule.name,
},
}
}
response = authenticated_client.patch(
reverse("mute-rule-detail", kwargs={"pk": second_rule.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "errors" in response.json()
def test_mute_rules_delete(self, authenticated_client, mute_rules_fixture):
"""Test deleting a mute rule."""
mute_rule = mute_rules_fixture[0]
response = authenticated_client.delete(
reverse("mute-rule-detail", kwargs={"pk": mute_rule.id})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify rule was deleted
from api.models import MuteRule
assert not MuteRule.objects.filter(id=mute_rule.id).exists()
def test_mute_rules_tenant_isolation(
self, authenticated_client, mute_rules_fixture, tenants_fixture
):
"""Test that users can only access mute rules from their tenant."""
# Create a second tenant with a mute rule
from api.models import MuteRule, Tenant
other_tenant = Tenant.objects.create(name="Other Tenant")
other_rule = MuteRule.objects.create(
tenant=other_tenant,
name="Other Tenant Rule",
reason="Should not be visible",
finding_uids=["test-uid"],
)
# Try to access other tenant's rule
response = authenticated_client.get(
reverse("mute-rule-detail", kwargs={"pk": other_rule.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
# List should only show current tenant's rules
response = authenticated_client.get(reverse("mute-rule-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == len(mute_rules_fixture)
for rule_data in data:
assert rule_data["id"] != str(other_rule.id)
@pytest.mark.parametrize(
"credentials",
[
{}, # empty credentials
{
"access_key_id": "AKIAIOSFODNN7EXAMPLE"
}, # missing secret_access_key and region
{
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}, # missing access_key_id and region
{
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}, # missing region
{ # invalid access_key_id format (not starting with AKIA)
"access_key_id": "ABCD0123456789ABCDEF",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"region": "us-east-1",
},
{ # invalid access_key_id format (wrong length)
"access_key_id": "AKIAIOSFODNN7EXAMPL",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"region": "us-east-1",
},
{ # invalid secret_access_key format (wrong length)
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEK",
"region": "us-east-1",
},
{ # invalid region format
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"region": "invalid-region",
},
{ # invalid region format (uppercase)
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"region": "US-EAST-1",
},
],
)
def test_bedrock_invalid_credentials(self, authenticated_client, credentials):
"""Bedrock provider with invalid credentials should error"""
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "bedrock",
"credentials": credentials,
},
}
}
resp = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
def test_bedrock_valid_credentials_success(self, authenticated_client):
"""Bedrock provider with valid AWS credentials should succeed and mask credentials"""
valid_credentials = {
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"region": "us-east-1",
}
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "bedrock",
"credentials": valid_credentials,
},
}
}
resp = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert resp.status_code == status.HTTP_201_CREATED
data = resp.json()["data"]
# Verify credentials are returned masked
masked_creds = data["attributes"].get("credentials")
assert masked_creds is not None
assert "access_key_id" in masked_creds
assert "secret_access_key" in masked_creds
assert "region" in masked_creds
# Verify all characters are masked with asterisks
assert all(c == "*" for c in masked_creds["access_key_id"])
assert all(c == "*" for c in masked_creds["secret_access_key"])
def test_bedrock_provider_duplicate_per_tenant(self, authenticated_client):
"""Creating a second Bedrock provider for same tenant should fail"""
valid_credentials = {
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"region": "us-west-2",
}
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "bedrock",
"credentials": valid_credentials,
},
}
}
# First creation succeeds
resp1 = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert resp1.status_code == status.HTTP_201_CREATED
# Second creation should fail with validation error
resp2 = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert resp2.status_code == status.HTTP_400_BAD_REQUEST
assert "already exists" in str(resp2.json()).lower()
def test_bedrock_patch_credentials_and_fields_filter(self, authenticated_client):
"""PATCH credentials and verify fields filter returns decrypted values"""
valid_credentials = {
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"region": "eu-west-1",
}
create_payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "bedrock",
"credentials": valid_credentials,
},
}
}
create_resp = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=create_payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert create_resp.status_code == status.HTTP_201_CREATED
provider_id = create_resp.json()["data"]["id"]
# Update credentials with new valid ones
new_credentials = {
"access_key_id": "AKIAZZZZZZZZZZZZZZZZ",
"secret_access_key": "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/==",
"region": "ap-south-1",
}
patch_payload = {
"data": {
"type": "lighthouse-providers",
"id": provider_id,
"attributes": {
"credentials": new_credentials,
"is_active": False,
},
}
}
patch_resp = authenticated_client.patch(
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
data=patch_payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert patch_resp.status_code == status.HTTP_200_OK
updated = patch_resp.json()["data"]["attributes"]
assert updated["is_active"] is False
# Default GET should return masked credentials
get_resp = authenticated_client.get(
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
)
assert get_resp.status_code == status.HTTP_200_OK
masked = get_resp.json()["data"]["attributes"]["credentials"]
assert all(c == "*" for c in masked["access_key_id"])
assert all(c == "*" for c in masked["secret_access_key"])
# Fields filter should return decrypted credentials
get_full = authenticated_client.get(
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
+ "?fields[lighthouse-providers]=credentials"
)
assert get_full.status_code == status.HTTP_200_OK
creds = get_full.json()["data"]["attributes"]["credentials"]
assert creds["access_key_id"] == new_credentials["access_key_id"]
assert creds["secret_access_key"] == new_credentials["secret_access_key"]
assert creds["region"] == new_credentials["region"]
def test_bedrock_partial_credential_update(self, authenticated_client):
"""Test partial update of Bedrock credentials (e.g., only region)"""
# Create provider with full credentials
initial_credentials = {
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"region": "us-east-1",
}
create_payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "bedrock",
"credentials": initial_credentials,
},
}
}
create_resp = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=create_payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert create_resp.status_code == status.HTTP_201_CREATED
provider_id = create_resp.json()["data"]["id"]
# Update only the region field
partial_update = {
"region": "eu-west-1",
}
patch_payload = {
"data": {
"type": "lighthouse-providers",
"id": provider_id,
"attributes": {
"credentials": partial_update,
},
}
}
patch_resp = authenticated_client.patch(
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
data=patch_payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert patch_resp.status_code == status.HTTP_200_OK
# Verify credentials with fields filter - region should be updated, keys preserved
get_full = authenticated_client.get(
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
+ "?fields[lighthouse-providers]=credentials"
)
assert get_full.status_code == status.HTTP_200_OK
creds = get_full.json()["data"]["attributes"]["credentials"]
# Original keys should be preserved
assert creds["access_key_id"] == initial_credentials["access_key_id"]
assert creds["secret_access_key"] == initial_credentials["secret_access_key"]
# Region should be updated
assert creds["region"] == "eu-west-1"
@pytest.mark.parametrize(
"attributes",
[
pytest.param(
{
"provider_type": "openai_compatible",
"credentials": {"api_key": "compat-key"},
},
id="missing",
),
pytest.param(
{
"provider_type": "openai_compatible",
"credentials": {"api_key": "compat-key"},
"base_url": "",
},
id="empty",
),
],
)
def test_openai_compatible_missing_base_url(self, authenticated_client, attributes):
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": attributes,
}
}
resp = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
error_detail = str(resp.json()).lower()
assert "base_url" in error_detail
def test_openai_compatible_invalid_credentials(self, authenticated_client):
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai_compatible",
"base_url": "https://compat.example/v1",
"credentials": {"api_key": ""},
},
}
}
resp = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
errors = resp.json().get("errors", [])
assert any(
error.get("source", {}).get("pointer")
== "/data/attributes/credentials/api_key"
for error in errors
)
assert any(
"may not be blank" in error.get("detail", "").lower() for error in errors
)
def test_openai_compatible_patch_credentials_and_fields(self, authenticated_client):
create_payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai_compatible",
"base_url": "https://compat.example/v1",
"credentials": {"api_key": "compat-key-123"},
},
}
}
create_resp = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=create_payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert create_resp.status_code == status.HTTP_201_CREATED
provider_id = create_resp.json()["data"]["id"]
updated_base_url = "https://compat.example/v2"
updated_api_key = "compat-key-456"
patch_payload = {
"data": {
"type": "lighthouse-providers",
"id": provider_id,
"attributes": {
"base_url": updated_base_url,
"credentials": {"api_key": updated_api_key},
},
}
}
patch_resp = authenticated_client.patch(
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
data=patch_payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert patch_resp.status_code == status.HTTP_200_OK
updated_attrs = patch_resp.json()["data"]["attributes"]
assert updated_attrs["base_url"] == updated_base_url
assert updated_attrs["credentials"]["api_key"] == "*" * len(updated_api_key)
get_resp = authenticated_client.get(
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
)
assert get_resp.status_code == status.HTTP_200_OK
masked = get_resp.json()["data"]["attributes"]["credentials"]["api_key"]
assert masked == "*" * len(updated_api_key)
get_full = authenticated_client.get(
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
+ "?fields[lighthouse-providers]=credentials"
)
assert get_full.status_code == status.HTTP_200_OK
creds = get_full.json()["data"]["attributes"]["credentials"]
assert creds["api_key"] == updated_api_key
@@ -1,6 +1,5 @@
import re
from drf_spectacular.utils import extend_schema_field
from rest_framework_json_api import serializers
@@ -12,198 +11,3 @@ class OpenAICredentialsSerializer(serializers.Serializer):
if not re.match(pattern, value or ""):
raise serializers.ValidationError("Invalid OpenAI API key format.")
return value
def to_internal_value(self, data):
"""Check for unknown fields before DRF filters them out."""
if not isinstance(data, dict):
raise serializers.ValidationError(
{"non_field_errors": ["Credentials must be an object"]}
)
allowed_fields = set(self.fields.keys())
provided_fields = set(data.keys())
extra_fields = provided_fields - allowed_fields
if extra_fields:
raise serializers.ValidationError(
{
"non_field_errors": [
f"Unknown fields in credentials: {', '.join(sorted(extra_fields))}"
]
}
)
return super().to_internal_value(data)
class BedrockCredentialsSerializer(serializers.Serializer):
"""
Serializer for AWS Bedrock credentials validation.
Validates long-term AWS credentials (AKIA) and region format.
"""
access_key_id = serializers.CharField()
secret_access_key = serializers.CharField()
region = serializers.CharField()
def validate_access_key_id(self, value: str) -> str:
"""Validate AWS access key ID format (AKIA for long-term credentials)."""
pattern = r"^AKIA[0-9A-Z]{16}$"
if not re.match(pattern, value or ""):
raise serializers.ValidationError(
"Invalid AWS access key ID format. Must be AKIA followed by 16 alphanumeric characters."
)
return value
def validate_secret_access_key(self, value: str) -> str:
"""Validate AWS secret access key format (40 base64 characters)."""
pattern = r"^[A-Za-z0-9/+=]{40}$"
if not re.match(pattern, value or ""):
raise serializers.ValidationError(
"Invalid AWS secret access key format. Must be 40 base64 characters."
)
return value
def validate_region(self, value: str) -> str:
"""Validate AWS region format."""
pattern = r"^[a-z]{2}-[a-z]+-\d+$"
if not re.match(pattern, value or ""):
raise serializers.ValidationError(
"Invalid AWS region format. Expected format like 'us-east-1' or 'eu-west-2'."
)
return value
def to_internal_value(self, data):
"""Check for unknown fields before DRF filters them out."""
if not isinstance(data, dict):
raise serializers.ValidationError(
{"non_field_errors": ["Credentials must be an object"]}
)
allowed_fields = set(self.fields.keys())
provided_fields = set(data.keys())
extra_fields = provided_fields - allowed_fields
if extra_fields:
raise serializers.ValidationError(
{
"non_field_errors": [
f"Unknown fields in credentials: {', '.join(sorted(extra_fields))}"
]
}
)
return super().to_internal_value(data)
class BedrockCredentialsUpdateSerializer(BedrockCredentialsSerializer):
"""
Serializer for AWS Bedrock credentials during UPDATE operations.
Inherits all validation logic from BedrockCredentialsSerializer but makes
all fields optional to support partial updates.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make all fields optional for updates
for field in self.fields.values():
field.required = False
class OpenAICompatibleCredentialsSerializer(serializers.Serializer):
"""
Minimal serializer for OpenAI-compatible credentials.
Many OpenAI-compatible providers do not use the same key format as OpenAI.
We only require a non-empty API key string. Additional fields can be added later
without breaking existing configurations.
"""
api_key = serializers.CharField()
def validate_api_key(self, value: str) -> str:
if not isinstance(value, str) or not value.strip():
raise serializers.ValidationError("API key is required.")
return value.strip()
def to_internal_value(self, data):
"""Check for unknown fields before DRF filters them out."""
if not isinstance(data, dict):
raise serializers.ValidationError(
{"non_field_errors": ["Credentials must be an object"]}
)
allowed_fields = set(self.fields.keys())
provided_fields = set(data.keys())
extra_fields = provided_fields - allowed_fields
if extra_fields:
raise serializers.ValidationError(
{
"non_field_errors": [
f"Unknown fields in credentials: {', '.join(sorted(extra_fields))}"
]
}
)
return super().to_internal_value(data)
@extend_schema_field(
{
"oneOf": [
{
"type": "object",
"title": "OpenAI Credentials",
"properties": {
"api_key": {
"type": "string",
"description": "OpenAI API key. Must start with 'sk-' followed by alphanumeric characters, "
"hyphens, or underscores.",
"pattern": "^sk-[\\w-]+$",
}
},
"required": ["api_key"],
},
{
"type": "object",
"title": "AWS Bedrock Credentials",
"properties": {
"access_key_id": {
"type": "string",
"description": "AWS access key ID.",
"pattern": "^AKIA[0-9A-Z]{16}$",
},
"secret_access_key": {
"type": "string",
"description": "AWS secret access key.",
"pattern": "^[A-Za-z0-9/+=]{40}$",
},
"region": {
"type": "string",
"description": "AWS region identifier where Bedrock is available. Examples: us-east-1, "
"us-west-2, eu-west-1, ap-northeast-1.",
"pattern": "^[a-z]{2}-[a-z]+-\\d+$",
},
},
"required": ["access_key_id", "secret_access_key", "region"],
},
{
"type": "object",
"title": "OpenAI Compatible Credentials",
"properties": {
"api_key": {
"type": "string",
"description": "API key for OpenAI-compatible provider. The format varies by provider. "
"Note: The 'base_url' field (separate from credentials) is required when using this provider type.",
}
},
"required": ["api_key"],
},
]
}
)
class LighthouseCredentialsField(serializers.JSONField):
pass
+6 -267
View File
@@ -31,7 +31,6 @@ from api.models import (
LighthouseProviderModels,
LighthouseTenantConfiguration,
Membership,
MuteRule,
Processor,
Provider,
ProviderGroup,
@@ -60,13 +59,7 @@ from api.v1.serializer_utils.integrations import (
S3ConfigSerializer,
SecurityHubConfigSerializer,
)
from api.v1.serializer_utils.lighthouse import (
BedrockCredentialsSerializer,
BedrockCredentialsUpdateSerializer,
LighthouseCredentialsField,
OpenAICompatibleCredentialsSerializer,
OpenAICredentialsSerializer,
)
from api.v1.serializer_utils.lighthouse import OpenAICredentialsSerializer
from api.v1.serializer_utils.processors import ProcessorConfigField
from api.v1.serializer_utils.providers import ProviderSecretField
from prowler.lib.mutelist.mutelist import Mutelist
@@ -3082,12 +3075,7 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
Accepts credentials as JSON; stored encrypted via credentials_decoded.
"""
credentials = LighthouseCredentialsField(write_only=True, required=True)
base_url = serializers.URLField(
required=False,
allow_null=True,
help_text="Base URL for the LLM provider API. Required for 'openai_compatible' provider type.",
)
credentials = serializers.JSONField(write_only=True, required=True)
class Meta:
model = LighthouseProviderConfiguration
@@ -3099,10 +3087,7 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
]
extra_kwargs = {
"is_active": {"required": False},
"provider_type": {
"help_text": "LLM provider type. Determines which credential format to use. "
"See 'credentials' field documentation for provider-specific requirements."
},
"base_url": {"required": False, "allow_null": True},
}
def create(self, validated_data):
@@ -3125,7 +3110,6 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
def validate(self, attrs):
provider_type = attrs.get("provider_type")
credentials = attrs.get("credentials") or {}
base_url = attrs.get("base_url")
if provider_type == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI:
try:
@@ -3138,35 +3122,6 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
elif (
provider_type == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK
):
try:
BedrockCredentialsSerializer(data=credentials).is_valid(
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
elif (
provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
):
if not base_url:
raise ValidationError({"base_url": "Base URL is required."})
try:
OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
return super().validate(attrs)
@@ -3176,12 +3131,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
Update serializer for LighthouseProviderConfiguration.
"""
credentials = LighthouseCredentialsField(write_only=True, required=False)
base_url = serializers.URLField(
required=False,
allow_null=True,
help_text="Base URL for the LLM provider API. Required for 'openai_compatible' provider type.",
)
credentials = serializers.JSONField(write_only=True, required=False)
class Meta:
model = LighthouseProviderConfiguration
@@ -3195,6 +3145,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
extra_kwargs = {
"id": {"read_only": True},
"provider_type": {"read_only": True},
"base_url": {"required": False, "allow_null": True},
"is_active": {"required": False},
}
@@ -3205,11 +3156,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
setattr(instance, attr, value)
if credentials is not None:
# Merge partial credentials with existing ones
# New values overwrite existing ones, but unspecified fields are preserved
existing_credentials = instance.credentials_decoded or {}
merged_credentials = {**existing_credentials, **credentials}
instance.credentials_decoded = merged_credentials
instance.credentials_decoded = credentials
instance.save()
return instance
@@ -3217,7 +3164,6 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
def validate(self, attrs):
provider_type = getattr(self.instance, "provider_type", None)
credentials = attrs.get("credentials", None)
base_url = attrs.get("base_url", None)
if (
credentials is not None
@@ -3234,40 +3180,6 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
elif (
credentials is not None
and provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK
):
try:
BedrockCredentialsUpdateSerializer(data=credentials).is_valid(
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
elif (
credentials is not None
and provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
):
if base_url is None:
pass
elif not base_url:
raise ValidationError({"base_url": "Base URL cannot be empty."})
try:
OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
return super().validate(attrs)
@@ -3433,176 +3345,3 @@ class LighthouseProviderModelsUpdateSerializer(BaseWriteSerializer):
extra_kwargs = {
"id": {"read_only": True},
}
# Mute Rules
class MuteRuleSerializer(RLSSerializer):
"""
Serializer for reading MuteRule instances.
"""
finding_uids = serializers.ListField(
child=serializers.CharField(),
read_only=True,
help_text="List of finding UIDs that are muted by this rule",
)
class Meta:
model = MuteRule
fields = [
"id",
"inserted_at",
"updated_at",
"name",
"reason",
"enabled",
"created_by",
"finding_uids",
]
included_serializers = {
"created_by": "api.v1.serializers.UserIncludeSerializer",
}
class MuteRuleCreateSerializer(RLSSerializer, BaseWriteSerializer):
"""
Serializer for creating new MuteRule instances.
Accepts finding_ids in the request, converts them to UIDs, and stores in finding_uids.
"""
finding_ids = serializers.ListField(
child=serializers.UUIDField(),
write_only=True,
required=True,
help_text="List of Finding IDs to mute (will be converted to UIDs)",
)
finding_uids = serializers.ListField(
child=serializers.CharField(),
read_only=True,
help_text="List of finding UIDs that are muted by this rule",
)
class Meta:
model = MuteRule
fields = [
"id",
"inserted_at",
"updated_at",
"name",
"reason",
"enabled",
"created_by",
"finding_ids",
"finding_uids",
]
extra_kwargs = {
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"enabled": {"read_only": True},
"created_by": {"read_only": True},
}
def validate_name(self, value):
"""Validate that the name is unique within the tenant."""
tenant_id = self.context.get("tenant_id")
if MuteRule.objects.filter(tenant_id=tenant_id, name=value).exists():
raise ValidationError("A mute rule with this name already exists.")
return value
def validate_finding_ids(self, value):
"""Validate that all finding IDs exist and belong to the tenant."""
if not value:
raise ValidationError("At least one finding_id must be provided.")
tenant_id = self.context.get("tenant_id")
# Check that all findings exist and belong to this tenant
findings = Finding.all_objects.filter(tenant_id=tenant_id, id__in=value)
found_ids = set(findings.values_list("id", flat=True))
provided_ids = set(value)
missing_ids = provided_ids - found_ids
if missing_ids:
raise ValidationError(
f"The following finding IDs do not exist or do not belong to your tenant: {missing_ids}"
)
return value
def validate(self, data):
"""Validate the entire mute rule, including overlap detection."""
data = super().validate(data)
tenant_id = self.context.get("tenant_id")
finding_ids = data.get("finding_ids", [])
if not finding_ids:
return data
# Convert finding IDs to UIDs (deduplicate in case multiple findings have same UID)
findings = Finding.all_objects.filter(id__in=finding_ids, tenant_id=tenant_id)
finding_uids = list(set(findings.values_list("uid", flat=True)))
# Check for overlaps with existing enabled rules
existing_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True)
for rule in existing_rules:
overlap = set(finding_uids) & set(rule.finding_uids)
if overlap:
raise ConflictException(
detail=f"The following finding UIDs are already muted by rule '{rule.name}': {overlap}"
)
# Store finding_uids in validated_data for create
data["finding_uids"] = finding_uids
return data
def create(self, validated_data):
"""Create a new mute rule and set created_by."""
# Remove finding_ids from validated_data (we've already converted to finding_uids)
validated_data.pop("finding_ids", None)
# Set created_by to the current user
request = self.context.get("request")
if request and hasattr(request, "user"):
validated_data["created_by"] = request.user
return super().create(validated_data)
class MuteRuleUpdateSerializer(BaseWriteSerializer):
"""
Serializer for updating MuteRule instances.
"""
class Meta:
model = MuteRule
fields = [
"id",
"name",
"reason",
"enabled",
]
extra_kwargs = {
"id": {"read_only": True},
"name": {"required": False},
"reason": {"required": False},
"enabled": {"required": False},
}
def validate_name(self, value):
"""Validate that the name is unique within the tenant, excluding current instance."""
tenant_id = self.context.get("tenant_id")
if (
MuteRule.objects.filter(tenant_id=tenant_id, name=value)
.exclude(id=self.instance.id)
.exists()
):
raise ValidationError("A mute rule with this name already exists.")
return value
+1 -2
View File
@@ -21,7 +21,6 @@ from api.v1.views import (
LighthouseProviderModelsViewSet,
LighthouseTenantConfigViewSet,
MembershipViewSet,
MuteRuleViewSet,
OverviewViewSet,
ProcessorViewSet,
ProviderGroupProvidersRelationshipView,
@@ -81,7 +80,6 @@ router.register(
LighthouseProviderModelsViewSet,
basename="lighthouse-models",
)
router.register(r"mute-rules", MuteRuleViewSet, basename="mute-rule")
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
@@ -152,6 +150,7 @@ urlpatterns = [
),
name="provider_group-providers-relationship",
),
# Lighthouse tenant config as singleton endpoint
path(
"lighthouse/configuration",
LighthouseTenantConfigViewSet.as_view(
+28 -114
View File
@@ -66,7 +66,6 @@ from tasks.tasks import (
delete_provider_task,
delete_tenant_task,
jira_integration_task,
mute_historical_findings_task,
perform_scan_task,
refresh_lighthouse_provider_models_task,
)
@@ -91,7 +90,6 @@ from api.filters import (
LighthouseProviderConfigFilter,
LighthouseProviderModelsFilter,
MembershipFilter,
MuteRuleFilter,
ProcessorFilter,
ProviderFilter,
ProviderGroupFilter,
@@ -117,7 +115,6 @@ from api.models import (
LighthouseProviderModels,
LighthouseTenantConfiguration,
Membership,
MuteRule,
Processor,
Provider,
ProviderGroup,
@@ -178,9 +175,6 @@ from api.v1.serializers import (
LighthouseTenantConfigSerializer,
LighthouseTenantConfigUpdateSerializer,
MembershipSerializer,
MuteRuleCreateSerializer,
MuteRuleSerializer,
MuteRuleUpdateSerializer,
OverviewFindingSerializer,
OverviewProviderCountSerializer,
OverviewProviderSerializer,
@@ -420,12 +414,6 @@ class SchemaView(SpectacularAPIView):
"description": "Endpoints for API keys management. These can be used as an alternative to JWT "
"authorization.",
},
{
"name": "Mute Rules",
"description": "Endpoints for simple mute rules management. These can be used as an alternative to the"
" Mutelist Processor if you need to mute specific findings across your tenant with a "
"specific reason.",
},
]
return super().get(request, *args, **kwargs)
@@ -4323,30 +4311,28 @@ class LighthouseConfigViewSet(BaseRLSViewSet):
@extend_schema_view(
list=extend_schema(
tags=["Lighthouse AI"],
summary="List all LLM provider configurations",
summary="List all LLM provider configs",
description="Retrieve all LLM provider configurations for the current tenant",
),
retrieve=extend_schema(
tags=["Lighthouse AI"],
summary="Retrieve LLM provider configuration",
summary="Retrieve LLM provider config",
description="Get details for a specific provider configuration in the current tenant.",
),
create=extend_schema(
tags=["Lighthouse AI"],
summary="Create LLM provider configuration",
description="Create a per-tenant configuration for an LLM provider. Only one configuration per provider type "
"is allowed per tenant.",
summary="Create LLM provider config",
description="Create a per-tenant configuration for an LLM provider. Only one configuration per provider type is allowed per tenant.",
),
partial_update=extend_schema(
tags=["Lighthouse AI"],
summary="Update LLM provider configuration",
summary="Update LLM provider config",
description="Partially update a provider configuration (e.g., base_url, is_active).",
),
destroy=extend_schema(
tags=["Lighthouse AI"],
summary="Delete LLM provider configuration",
description="Delete a provider configuration. Any tenant defaults that reference this provider are cleared "
"during deletion.",
summary="Delete LLM provider config",
description="Delete a provider configuration. Any tenant defaults that reference this provider are cleared during deletion.",
),
)
class LighthouseProviderConfigViewSet(BaseRLSViewSet):
@@ -4411,6 +4397,16 @@ class LighthouseProviderConfigViewSet(BaseRLSViewSet):
@action(detail=True, methods=["post"], url_name="connection")
def connection(self, request, pk=None):
instance = self.get_object()
if (
instance.provider_type
!= LighthouseProviderConfiguration.LLMProviderChoices.OPENAI
):
return Response(
data={
"errors": [{"detail": "Only 'openai' provider supported in MVP"}]
},
status=status.HTTP_400_BAD_REQUEST,
)
with transaction.atomic():
task = check_lighthouse_provider_connection_task.delay(
@@ -4432,7 +4428,7 @@ class LighthouseProviderConfigViewSet(BaseRLSViewSet):
@extend_schema(
tags=["Lighthouse AI"],
summary="Refresh LLM models catalog",
description="Fetch available models for this provider configuration and upsert into catalog. Supports OpenAI, OpenAI-compatible, and AWS Bedrock providers.",
description="Fetch available models for this provider configuration and upsert into catalog.",
request=None,
responses={202: OpenApiResponse(response=TaskSerializer)},
)
@@ -4444,6 +4440,16 @@ class LighthouseProviderConfigViewSet(BaseRLSViewSet):
)
def refresh_models(self, request, pk=None):
instance = self.get_object()
if (
instance.provider_type
!= LighthouseProviderConfiguration.LLMProviderChoices.OPENAI
):
return Response(
data={
"errors": [{"detail": "Only 'openai' provider supported in MVP"}]
},
status=status.HTTP_400_BAD_REQUEST,
)
with transaction.atomic():
task = refresh_lighthouse_provider_models_task.delay(
@@ -4699,95 +4705,3 @@ class TenantApiKeyViewSet(BaseRLSViewSet):
serializer = self.get_serializer(instance)
return Response(data=serializer.data, status=status.HTTP_200_OK)
# MuteRules
@extend_schema_view(
list=extend_schema(
tags=["Mute Rules"],
summary="List all mute rules",
description="Retrieve a list of all mute rules with filtering options.",
),
retrieve=extend_schema(
tags=["Mute Rules"],
summary="Retrieve a mute rule",
description="Fetch detailed information about a specific mute rule by ID.",
),
create=extend_schema(
tags=["Mute Rules"],
summary="Create a new mute rule",
description="Create a new mute rule by providing finding IDs, name, and reason. "
"The rule will immediately mute the selected findings and launch a background task "
"to mute all historical findings with matching UIDs.",
request=MuteRuleCreateSerializer,
),
partial_update=extend_schema(
tags=["Mute Rules"],
summary="Partially update a mute rule",
description="Update certain fields of an existing mute rule (e.g., name, reason, enabled).",
request=MuteRuleUpdateSerializer,
responses={200: MuteRuleSerializer},
),
destroy=extend_schema(
tags=["Mute Rules"],
summary="Delete a mute rule",
description="Remove a mute rule from the system. Note: Previously muted findings remain muted.",
),
)
class MuteRuleViewSet(BaseRLSViewSet):
queryset = MuteRule.objects.all()
serializer_class = MuteRuleSerializer
filterset_class = MuteRuleFilter
http_method_names = ["get", "post", "patch", "delete"]
search_fields = ["name", "reason"]
ordering = ["-inserted_at"]
ordering_fields = [
"name",
"enabled",
"inserted_at",
"updated_at",
]
required_permissions = [Permissions.MANAGE_SCANS]
def get_queryset(self):
queryset = MuteRule.objects.filter(tenant_id=self.request.tenant_id)
return queryset.select_related("created_by")
def get_serializer_class(self):
if self.action == "create":
return MuteRuleCreateSerializer
elif self.action == "partial_update":
return MuteRuleUpdateSerializer
return super().get_serializer_class()
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Create the mute rule
mute_rule = serializer.save()
tenant_id = str(request.tenant_id)
finding_ids = request.data.get("finding_ids", [])
# Immediately mute the selected findings
Finding.all_objects.filter(
id__in=finding_ids, tenant_id=tenant_id, muted=False
).update(
muted=True,
muted_at=mute_rule.inserted_at,
muted_reason=mute_rule.reason,
)
# Launch background task for historical muting
with transaction.atomic():
mute_historical_findings_task.apply_async(
kwargs={"tenant_id": tenant_id, "mute_rule_id": str(mute_rule.id)}
)
# Return the created mute rule
serializer = self.get_serializer(mute_rule)
return Response(
data=serializer.data,
status=status.HTTP_201_CREATED,
)
-29
View File
@@ -23,7 +23,6 @@ from api.models import (
Invitation,
LighthouseConfiguration,
Membership,
MuteRule,
Processor,
Provider,
ProviderGroup,
@@ -1426,34 +1425,6 @@ def api_keys_fixture(tenants_fixture, create_test_user):
return [api_key1, api_key2, api_key3]
@pytest.fixture
def mute_rules_fixture(tenants_fixture, create_test_user, findings_fixture):
"""Create test mute rules for testing."""
tenant = tenants_fixture[0]
user = create_test_user
# Create two mute rules: one enabled, one disabled
mute_rule1 = MuteRule.objects.create(
tenant_id=tenant.id,
name="Test Rule 1",
reason="Security exception for testing",
enabled=True,
created_by=user,
finding_uids=[findings_fixture[0].uid],
)
mute_rule2 = MuteRule.objects.create(
tenant_id=tenant.id,
name="Test Rule 2",
reason="Compliance exception approved",
enabled=False,
created_by=user,
finding_uids=[findings_fixture[1].uid],
)
return mute_rule1, mute_rule2
def get_authorization_header(access_token: str) -> dict:
return {"Authorization": f"Bearer {access_token}"}
@@ -1,8 +1,6 @@
from typing import Dict
from typing import Dict, Set
import boto3
import openai
from botocore.exceptions import BotoCoreError, ClientError
from celery.utils.log import get_task_logger
from api.models import LighthouseProviderConfiguration, LighthouseProviderModels
@@ -32,71 +30,16 @@ def _extract_openai_api_key(
return api_key
def _extract_openai_compatible_params(
provider_cfg: LighthouseProviderConfiguration,
) -> Dict[str, str] | None:
"""
Extract base_url and api_key for OpenAI-compatible providers.
"""
creds = provider_cfg.credentials_decoded
base_url = provider_cfg.base_url
if not isinstance(creds, dict):
return None
api_key = creds.get("api_key")
if not isinstance(api_key, str) or not api_key:
return None
if not isinstance(base_url, str) or not base_url:
return None
return {"base_url": base_url, "api_key": api_key}
def _extract_bedrock_credentials(
provider_cfg: LighthouseProviderConfiguration,
) -> Dict[str, str] | None:
"""
Safely extract AWS Bedrock credentials from a provider configuration.
Args:
provider_cfg (LighthouseProviderConfiguration): The provider configuration instance
containing the credentials.
Returns:
Dict[str, str] | None: Dictionary with 'access_key_id', 'secret_access_key', and
'region' if present and valid, otherwise None.
"""
creds = provider_cfg.credentials_decoded
if not isinstance(creds, dict):
return None
access_key_id = creds.get("access_key_id")
secret_access_key = creds.get("secret_access_key")
region = creds.get("region")
# Validate all required fields are present and are strings
if (
not isinstance(access_key_id, str)
or not access_key_id
or not isinstance(secret_access_key, str)
or not secret_access_key
or not isinstance(region, str)
or not region
):
return None
return {
"access_key_id": access_key_id,
"secret_access_key": secret_access_key,
"region": region,
}
def check_lighthouse_provider_connection(provider_config_id: str) -> Dict:
"""
Validate a Lighthouse provider configuration by calling the provider API and
toggle its active state accordingly.
Currently supports the OpenAI provider by invoking `models.list` to verify that
the provided credentials are valid.
Args:
provider_config_id: The primary key of the `LighthouseProviderConfiguration`
provider_config_id (str): The primary key of the `LighthouseProviderConfiguration`
to validate.
Returns:
@@ -112,238 +55,42 @@ def check_lighthouse_provider_connection(provider_config_id: str) -> Dict:
"""
provider_cfg = LighthouseProviderConfiguration.objects.get(pk=provider_config_id)
# TODO: Add support for other providers
if (
provider_cfg.provider_type
!= LighthouseProviderConfiguration.LLMProviderChoices.OPENAI
):
return {"connected": False, "error": "Unsupported provider type"}
api_key = _extract_openai_api_key(provider_cfg)
if not api_key:
provider_cfg.is_active = False
provider_cfg.save()
return {"connected": False, "error": "API key is invalid or missing"}
try:
if (
provider_cfg.provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI
):
api_key = _extract_openai_api_key(provider_cfg)
if not api_key:
provider_cfg.is_active = False
provider_cfg.save()
return {"connected": False, "error": "API key is invalid or missing"}
# Test connection by listing models
client = openai.OpenAI(api_key=api_key)
_ = client.models.list()
elif (
provider_cfg.provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK
):
bedrock_creds = _extract_bedrock_credentials(provider_cfg)
if not bedrock_creds:
provider_cfg.is_active = False
provider_cfg.save()
return {
"connected": False,
"error": "AWS credentials are invalid or missing",
}
# Test connection by listing foundation models
bedrock_client = boto3.client(
"bedrock",
aws_access_key_id=bedrock_creds["access_key_id"],
aws_secret_access_key=bedrock_creds["secret_access_key"],
region_name=bedrock_creds["region"],
)
_ = bedrock_client.list_foundation_models()
elif (
provider_cfg.provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
):
params = _extract_openai_compatible_params(provider_cfg)
if not params:
provider_cfg.is_active = False
provider_cfg.save()
return {
"connected": False,
"error": "Base URL or API key is invalid or missing",
}
# Test connection using OpenAI SDK with custom base_url
# Note: base_url should include version (e.g., https://openrouter.ai/api/v1)
client = openai.OpenAI(
api_key=params["api_key"],
base_url=params["base_url"],
)
_ = client.models.list()
else:
return {"connected": False, "error": "Unsupported provider type"}
# Connection successful
client = openai.OpenAI(api_key=api_key)
_ = client.models.list()
provider_cfg.is_active = True
provider_cfg.save()
return {"connected": True, "error": None}
except Exception as e:
logger.warning(
"%s connection check failed: %s", provider_cfg.provider_type, str(e)
)
logger.warning("OpenAI connection check failed: %s", str(e))
provider_cfg.is_active = False
provider_cfg.save()
return {"connected": False, "error": str(e)}
def _fetch_openai_models(api_key: str) -> Dict[str, str]:
"""
Fetch available models from OpenAI API.
Args:
api_key: OpenAI API key for authentication.
Returns:
Dict mapping model_id to model_name. For OpenAI, both are the same
as the API doesn't provide separate display names.
Raises:
Exception: If the API call fails.
"""
client = openai.OpenAI(api_key=api_key)
models = client.models.list()
# OpenAI uses model.id for both ID and display name
return {m.id: m.id for m in getattr(models, "data", [])}
def _fetch_openai_compatible_models(base_url: str, api_key: str) -> Dict[str, str]:
"""
Fetch available models from an OpenAI-compatible API using the OpenAI SDK.
Returns a mapping of model_id -> model_name. Prefers the 'name' attribute
if available (e.g., from OpenRouter), otherwise falls back to 'id'.
Note: base_url should include version (e.g., https://openrouter.ai/api/v1)
"""
client = openai.OpenAI(api_key=api_key, base_url=base_url)
models = client.models.list()
available_models: Dict[str, str] = {}
for model in models.data:
model_id = model.id
# Prefer provider-supplied human-friendly name when available
name = getattr(model, "name", None)
if name:
available_models[model_id] = name
else:
available_models[model_id] = model_id
return available_models
def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]:
"""
Fetch available models from AWS Bedrock with entitlement verification.
This function:
1. Lists foundation models with TEXT modality support
2. Lists inference profiles with TEXT modality support
3. Verifies user has entitlement access to each model
Args:
bedrock_creds: Dictionary with 'access_key_id', 'secret_access_key', and 'region'.
Returns:
Dict mapping model_id to model_name for all accessible models.
Raises:
BotoCoreError, ClientError: If AWS API calls fail.
"""
bedrock_client = boto3.client(
"bedrock",
aws_access_key_id=bedrock_creds["access_key_id"],
aws_secret_access_key=bedrock_creds["secret_access_key"],
region_name=bedrock_creds["region"],
)
models_to_check: Dict[str, str] = {}
# Step 1: Get foundation models with TEXT modality
foundation_response = bedrock_client.list_foundation_models()
model_summaries = foundation_response.get("modelSummaries", [])
for model in model_summaries:
# Check if model supports TEXT input and output modality
input_modalities = model.get("inputModalities", [])
output_modalities = model.get("outputModalities", [])
if "TEXT" not in input_modalities or "TEXT" not in output_modalities:
continue
model_id = model.get("modelId")
if not model_id:
continue
inference_types = model.get("inferenceTypesSupported", [])
# Only include models with ON_DEMAND inference support
if "ON_DEMAND" in inference_types:
models_to_check[model_id] = model["modelName"]
# Step 2: Get inference profiles
try:
inference_profiles_response = bedrock_client.list_inference_profiles()
inference_profiles = inference_profiles_response.get(
"inferenceProfileSummaries", []
)
for profile in inference_profiles:
# Check if profile supports TEXT modality
input_modalities = profile.get("inputModalities", [])
output_modalities = profile.get("outputModalities", [])
if "TEXT" not in input_modalities or "TEXT" not in output_modalities:
continue
profile_id = profile.get("inferenceProfileId")
if profile_id:
models_to_check[profile_id] = profile["inferenceProfileName"]
except (BotoCoreError, ClientError) as e:
logger.info(
"Could not fetch inference profiles in %s: %s",
bedrock_creds["region"],
str(e),
)
# Step 3: Verify entitlement availability for each model
available_models: Dict[str, str] = {}
for model_id, model_name in models_to_check.items():
try:
availability = bedrock_client.get_foundation_model_availability(
modelId=model_id
)
entitlement = availability.get("entitlementAvailability")
# Only include models user has access to
if entitlement == "AVAILABLE":
available_models[model_id] = model_name
else:
logger.debug(
"Skipping model %s - entitlement status: %s", model_id, entitlement
)
except (BotoCoreError, ClientError) as e:
logger.debug(
"Could not check availability for model %s: %s", model_id, str(e)
)
continue
return available_models
def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict:
"""
Refresh the catalog of models for a Lighthouse provider configuration.
Fetches the current list of models from the provider, upserts entries into
`LighthouseProviderModels`, and deletes stale entries no longer returned.
For the OpenAI provider, this fetches the current list of models, upserts entries
into `LighthouseProviderModels`, and deletes stale entries no longer returned by
the provider.
Args:
provider_config_id: The primary key of the `LighthouseProviderConfiguration`
provider_config_id (str): The primary key of the `LighthouseProviderConfiguration`
whose models should be refreshed.
Returns:
@@ -357,81 +104,45 @@ def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict:
LighthouseProviderConfiguration.DoesNotExist: If no configuration exists with the given ID.
"""
provider_cfg = LighthouseProviderConfiguration.objects.get(pk=provider_config_id)
fetched_models: Dict[str, str] = {}
# Fetch models from the appropriate provider
if (
provider_cfg.provider_type
!= LighthouseProviderConfiguration.LLMProviderChoices.OPENAI
):
return {
"created": 0,
"updated": 0,
"deleted": 0,
"error": "Unsupported provider type",
}
api_key = _extract_openai_api_key(provider_cfg)
if not api_key:
return {
"created": 0,
"updated": 0,
"deleted": 0,
"error": "API key is invalid or missing",
}
try:
if (
provider_cfg.provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI
):
api_key = _extract_openai_api_key(provider_cfg)
if not api_key:
return {
"created": 0,
"updated": 0,
"deleted": 0,
"error": "API key is invalid or missing",
}
fetched_models = _fetch_openai_models(api_key)
elif (
provider_cfg.provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK
):
bedrock_creds = _extract_bedrock_credentials(provider_cfg)
if not bedrock_creds:
return {
"created": 0,
"updated": 0,
"deleted": 0,
"error": "AWS credentials are invalid or missing",
}
fetched_models = _fetch_bedrock_models(bedrock_creds)
elif (
provider_cfg.provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
):
params = _extract_openai_compatible_params(provider_cfg)
if not params:
return {
"created": 0,
"updated": 0,
"deleted": 0,
"error": "Base URL or API key is invalid or missing",
}
fetched_models = _fetch_openai_compatible_models(
params["base_url"], params["api_key"]
)
else:
return {
"created": 0,
"updated": 0,
"deleted": 0,
"error": "Unsupported provider type",
}
except Exception as e:
logger.warning(
"Unexpected error refreshing %s models: %s",
provider_cfg.provider_type,
str(e),
)
client = openai.OpenAI(api_key=api_key)
models = client.models.list()
fetched_ids: Set[str] = {m.id for m in getattr(models, "data", [])}
except Exception as e: # noqa: BLE001
logger.warning("OpenAI models refresh failed: %s", str(e))
return {"created": 0, "updated": 0, "deleted": 0, "error": str(e)}
# Upsert models into the catalog
created = 0
updated = 0
for model_id, model_name in fetched_models.items():
for model_id in fetched_ids:
obj, was_created = LighthouseProviderModels.objects.update_or_create(
tenant_id=provider_cfg.tenant_id,
provider_configuration=provider_cfg,
model_id=model_id,
defaults={
"model_name": model_name,
"model_name": model_id, # OpenAI doesn't return a separate display name
"default_parameters": {},
},
)
@@ -445,7 +156,7 @@ def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict:
LighthouseProviderModels.objects.filter(
tenant_id=provider_cfg.tenant_id, provider_configuration=provider_cfg
)
.exclude(model_id__in=fetched_models.keys())
.exclude(model_id__in=fetched_ids)
.delete()
)
-64
View File
@@ -1,64 +0,0 @@
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from tasks.utils import batched
from api.db_utils import rls_transaction
from api.models import Finding, MuteRule
logger = get_task_logger(__name__)
def mute_historical_findings(tenant_id: str, mute_rule_id: str):
"""
Mute historical findings that match the given mute rule.
This function processes findings in batches, updating their muted status
and adding the mute reason.
Args:
tenant_id (str): The tenant ID for RLS context
mute_rule_id (str): The ID of the mute rule to apply
Returns:
dict: Summary of the muting operation with findings_muted count
"""
findings_muted_count = 0
# Get the list of UIDs to mute and the reason
with rls_transaction(tenant_id):
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
finding_uids = mute_rule.finding_uids
mute_reason = mute_rule.reason
muted_at = mute_rule.inserted_at
# Query findings that match the UIDs and are not already muted
with rls_transaction(tenant_id):
findings_to_mute = Finding.objects.filter(
tenant_id=tenant_id, uid__in=finding_uids, muted=False
)
total_findings = findings_to_mute.count()
logger.info(
f"Processing {total_findings} findings for mute rule {mute_rule_id}"
)
if total_findings > 0:
for batch, is_last in batched(
findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE
):
batch_ids = [f.id for f in batch]
updated_count = Finding.all_objects.filter(
id__in=batch_ids, tenant_id=tenant_id
).update(
muted=True,
muted_at=muted_at,
muted_reason=mute_reason,
)
findings_muted_count += updated_count
logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}")
return {
"findings_muted": findings_muted_count,
"rule_id": mute_rule_id,
}
+4 -31
View File
@@ -30,7 +30,6 @@ from api.exceptions import ProviderConnectionError
from api.models import (
ComplianceRequirementOverview,
Finding,
MuteRule,
Processor,
Provider,
Resource,
@@ -302,20 +301,6 @@ def perform_prowler_scan(
logger.error(f"Error processing mutelist rules: {e}")
mutelist_processor = None
# Load enabled mute rules for this tenant
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
try:
active_mute_rules = MuteRule.objects.filter(
tenant_id=tenant_id, enabled=True
).values_list("finding_uids", "reason")
mute_rules_cache = {}
for finding_uids, reason in active_mute_rules:
for uid in finding_uids:
mute_rules_cache[uid] = reason
except Exception as e:
logger.error(f"Error loading mute rules: {e}")
mute_rules_cache = {}
try:
with rls_transaction(tenant_id):
try:
@@ -464,22 +449,11 @@ def perform_prowler_scan(
if not last_first_seen_at:
last_first_seen_at = datetime.now(tz=timezone.utc)
# Determine if finding should be muted and why
# Priority: mutelist processor (highest) > manual mute rules
is_muted = False
muted_reason = None
# Check mutelist processor first (highest priority)
if finding.muted:
is_muted = True
muted_reason = "Muted by mutelist"
# If not muted by mutelist, check manual mute rules
elif finding_uid in mute_rules_cache:
is_muted = True
muted_reason = mute_rules_cache[finding_uid]
# If the finding is muted at this time the reason must be the configured Mutelist
muted_reason = "Muted by mutelist" if finding.muted else None
# Increment failed_findings_count cache if the finding status is FAIL and not muted
if status == FindingStatus.FAIL and not is_muted:
if status == FindingStatus.FAIL and not finding.muted:
resource_uid = finding.resource_uid
resource_failed_findings_cache[resource_uid] += 1
@@ -498,8 +472,7 @@ def perform_prowler_scan(
check_id=finding.check_id,
scan=scan_instance,
first_seen_at=last_first_seen_at,
muted=is_muted,
muted_at=datetime.now(tz=timezone.utc) if is_muted else None,
muted=finding.muted,
muted_reason=muted_reason,
compliance=finding.compliance,
)
-23
View File
@@ -31,7 +31,6 @@ from tasks.jobs.lighthouse_providers import (
check_lighthouse_provider_connection,
refresh_lighthouse_provider_models,
)
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.report import generate_threatscore_report_job
from tasks.jobs.scan import (
aggregate_findings,
@@ -682,25 +681,3 @@ def generate_threatscore_report_task(tenant_id: str, scan_id: str, provider_id:
return generate_threatscore_report_job(
tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id
)
@shared_task(name="findings-mute-historical")
def mute_historical_findings_task(tenant_id: str, mute_rule_id: str):
"""
Background task to mute all historical findings matching a mute rule.
This task processes findings in batches to avoid memory issues with large datasets.
It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields
for all findings whose UID is in the mute rule's finding_uids list.
Args:
tenant_id (str): The tenant ID for RLS context.
mute_rule_id (str): The primary key of the MuteRule to apply.
Returns:
dict: A dictionary containing:
- 'findings_muted' (int): Total number of findings muted.
- 'rule_id' (str): The mute rule ID.
- 'status' (str): Final status ('completed').
"""
return mute_historical_findings(tenant_id, mute_rule_id)
-532
View File
@@ -1,532 +0,0 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from django.core.exceptions import ObjectDoesNotExist
from tasks.jobs.muting import mute_historical_findings
from api.models import Finding, MuteRule
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
@pytest.mark.django_db
class TestMuteHistoricalFindings:
"""
Test suite for the mute_historical_findings function.
This class tests the batch processing of findings to update their muted status
based on MuteRule criteria.
"""
@pytest.fixture(scope="function")
def test_user(self, create_test_user):
"""Create a test user for mute rule creation."""
return create_test_user
@pytest.fixture(scope="function")
def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user):
"""
Create a mute rule that targets the first finding in the fixture.
"""
tenant = tenants_fixture[0]
finding = findings_fixture[0]
mute_rule = MuteRule.objects.create(
tenant_id=tenant.id,
name="Test Mute Rule",
reason="Testing mute functionality",
enabled=True,
created_by=test_user,
finding_uids=[finding.uid],
)
return mute_rule
@pytest.fixture(scope="function")
def mute_rule_multiple_findings(self, scans_fixture, test_user):
"""
Create multiple unmuted findings and a mute rule targeting all of them.
"""
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 5 unmuted findings
finding_uids = []
for i in range(5):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"test_finding_uid_mute_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Test status {i}",
impact=Severity.high,
severity=Severity.high,
raw_result={
"status": Status.FAIL,
"impact": Severity.high,
"severity": Severity.high,
},
check_id=f"test_check_id_{i}",
check_metadata={
"CheckId": f"test_check_id_{i}",
"Description": f"Test description {i}",
},
muted=False,
)
finding_uids.append(finding.uid)
# Create mute rule targeting all findings
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Multiple Findings Mute Rule",
reason="Testing batch muting",
enabled=True,
created_by=test_user,
finding_uids=finding_uids,
)
return mute_rule, finding_uids
@pytest.fixture(scope="function")
def mute_rule_already_muted(self, findings_fixture, test_user):
"""
Create a mute rule that targets an already-muted finding.
"""
tenant_id = findings_fixture[1].tenant_id
already_muted_finding = findings_fixture[1]
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Already Muted Rule",
reason="Testing already muted findings",
enabled=True,
created_by=test_user,
finding_uids=[already_muted_finding.uid],
)
return mute_rule
@pytest.fixture(scope="function")
def mute_rule_mixed_findings(self, scans_fixture, test_user):
"""
Create a mute rule with a mix of muted and unmuted findings.
"""
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 3 unmuted findings
unmuted_uids = []
for i in range(3):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"unmuted_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Unmuted status {i}",
impact=Severity.medium,
severity=Severity.medium,
raw_result={
"status": Status.FAIL,
"impact": Severity.medium,
"severity": Severity.medium,
},
check_id=f"unmuted_check_{i}",
check_metadata={
"CheckId": f"unmuted_check_{i}",
"Description": f"Unmuted description {i}",
},
muted=False,
)
unmuted_uids.append(finding.uid)
# Create 2 already muted findings
muted_uids = []
for i in range(2):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"muted_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Muted status {i}",
impact=Severity.low,
severity=Severity.low,
raw_result={
"status": Status.FAIL,
"impact": Severity.low,
"severity": Severity.low,
},
check_id=f"muted_check_{i}",
check_metadata={
"CheckId": f"muted_check_{i}",
"Description": f"Muted description {i}",
},
muted=True,
muted_at=datetime.now(timezone.utc),
muted_reason="Already muted",
)
muted_uids.append(finding.uid)
# Create mute rule targeting all findings
all_uids = unmuted_uids + muted_uids
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Mixed Findings Rule",
reason="Testing mixed muted/unmuted findings",
enabled=True,
created_by=test_user,
finding_uids=all_uids,
)
return mute_rule, unmuted_uids, muted_uids
@pytest.fixture(scope="function")
def mute_rule_batch_test(self, scans_fixture, test_user):
"""
Create enough findings to test batch processing (>1000 for default batch size).
"""
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 1500 findings to exceed default batch size of 1000
finding_uids = []
for i in range(1500):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"batch_test_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Batch test status {i}",
impact=Severity.critical,
severity=Severity.critical,
raw_result={
"status": Status.FAIL,
"impact": Severity.critical,
"severity": Severity.critical,
},
check_id=f"batch_test_check_{i}",
check_metadata={
"CheckId": f"batch_test_check_{i}",
"Description": f"Batch test description {i}",
},
muted=False,
)
finding_uids.append(finding.uid)
# Create mute rule targeting all findings
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Batch Processing Rule",
reason="Testing batch processing functionality",
enabled=True,
created_by=test_user,
finding_uids=finding_uids,
)
return mute_rule, finding_uids
def test_mute_historical_findings_single_finding(
self, mute_rule_with_findings, findings_fixture
):
"""
Test muting a single historical finding.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[0]
# Ensure the finding is not muted before execution
finding.refresh_from_db()
assert finding.muted is False
assert finding.muted_at is None
assert finding.muted_reason is None
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 1
assert result["rule_id"] == str(mute_rule.id)
# Verify the finding was muted
finding.refresh_from_db()
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_multiple_findings(
self, mute_rule_multiple_findings
):
"""
Test muting multiple historical findings.
"""
mute_rule, finding_uids = mute_rule_multiple_findings
tenant_id = str(mute_rule.tenant_id)
# Verify all findings are unmuted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
assert findings.count() == 5
for finding in findings:
assert finding.muted is False
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 5
assert result["rule_id"] == str(mute_rule.id)
# Verify all findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_already_muted(
self, mute_rule_already_muted, findings_fixture
):
"""
Test that already-muted findings are not counted or updated.
"""
mute_rule = mute_rule_already_muted
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[1]
# Verify the finding is already muted
finding.refresh_from_db()
assert finding.muted is True
original_muted_at = finding.muted_at
original_muted_reason = finding.muted_reason
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
# Verify the finding's mute status did not change
finding.refresh_from_db()
assert finding.muted is True
assert finding.muted_at == original_muted_at
assert finding.muted_reason == original_muted_reason
def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings):
"""
Test muting when some findings are already muted and others are not.
"""
mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings
tenant_id = str(mute_rule.tenant_id)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify only unmuted findings were counted
assert result["findings_muted"] == 3
assert result["rule_id"] == str(mute_rule.id)
# Verify unmuted findings are now muted
unmuted_findings = Finding.objects.filter(
tenant_id=tenant_id, uid__in=unmuted_uids
)
for finding in unmuted_findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
# Verify already-muted findings remained unchanged
already_muted_findings = Finding.objects.filter(
tenant_id=tenant_id, uid__in=muted_uids
)
for finding in already_muted_findings:
assert finding.muted is True
assert finding.muted_reason == "Already muted"
def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture):
"""
Test that a nonexistent mute rule raises ObjectDoesNotExist.
"""
tenant_id = str(tenants_fixture[0].id)
nonexistent_rule_id = str(uuid4())
with pytest.raises(ObjectDoesNotExist):
mute_historical_findings(tenant_id, nonexistent_rule_id)
def test_mute_historical_findings_no_matching_findings(
self, tenants_fixture, test_user
):
"""
Test muting when no findings match the rule's UIDs.
"""
tenant_id = str(tenants_fixture[0].id)
# Create a mute rule with non-existent finding UIDs
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test No Match Rule",
reason="Testing no matching findings",
enabled=True,
created_by=test_user,
finding_uids=[
"nonexistent_uid_1",
"nonexistent_uid_2",
"nonexistent_uid_3",
],
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test):
"""
Test that large numbers of findings are processed in batches correctly.
"""
mute_rule, finding_uids = mute_rule_batch_test
tenant_id = str(mute_rule.tenant_id)
# Verify all findings exist and are unmuted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
assert findings.count() == 1500
for finding in findings:
assert finding.muted is False
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 1500
assert result["rule_id"] == str(mute_rule.id)
# Verify all findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_preserves_muted_at_timestamp(
self, mute_rule_with_findings, findings_fixture
):
"""
Test that muted_at is set to the rule's inserted_at, not the current time.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[0]
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify the finding was muted
assert result["findings_muted"] == 1
# Verify muted_at matches the rule's inserted_at timestamp
finding.refresh_from_db()
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_at is not None
def test_mute_historical_findings_partial_match(self, scans_fixture, test_user):
"""
Test muting when only some of the rule's UIDs exist as findings.
"""
scan = scans_fixture[0]
tenant_id = str(scan.tenant_id)
# Create 3 findings
existing_uids = []
for i in range(3):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"partial_match_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Partial match status {i}",
impact=Severity.high,
severity=Severity.high,
raw_result={
"status": Status.FAIL,
"impact": Severity.high,
"severity": Severity.high,
},
check_id=f"partial_match_check_{i}",
check_metadata={
"CheckId": f"partial_match_check_{i}",
"Description": f"Partial match description {i}",
},
muted=False,
)
existing_uids.append(finding.uid)
# Create a mute rule with both existing and non-existing UIDs
all_uids = existing_uids + [
"nonexistent_uid_1",
"nonexistent_uid_2",
]
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Partial Match Rule",
reason="Testing partial matching",
enabled=True,
created_by=test_user,
finding_uids=all_uids,
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify only existing findings were muted
assert result["findings_muted"] == 3
assert result["rule_id"] == str(mute_rule.id)
# Verify the existing findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids)
assert findings.count() == 3
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user):
"""
Test muting when the rule has an empty finding_uids array.
"""
tenant_id = str(tenants_fixture[0].id)
# Create a mute rule with empty finding_uids
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Empty UIDs Rule",
reason="Testing empty UIDs",
enabled=True,
created_by=test_user,
finding_uids=[],
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
def test_mute_historical_findings_return_format(self, mute_rule_with_findings):
"""
Test that the return value has the correct format and fields.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value structure
assert isinstance(result, dict)
assert "findings_muted" in result
assert "rule_id" in result
assert isinstance(result["findings_muted"], int)
assert isinstance(result["rule_id"], str)
assert result["rule_id"] == str(mute_rule.id)
+1 -564
View File
@@ -18,15 +18,7 @@ from tasks.utils import CustomEncoder
from api.db_router import MainRouter
from api.exceptions import ProviderConnectionError
from api.models import (
Finding,
MuteRule,
Provider,
Resource,
Scan,
StateChoices,
StatusChoices,
)
from api.models import Finding, Provider, Resource, Scan, StateChoices, StatusChoices
from prowler.lib.check.models import Severity
@@ -747,561 +739,6 @@ class TestPerformScan:
# Assert that failed_findings_count was reset to 0 during the scan
assert resource.failed_findings_count == 0
def test_perform_prowler_scan_with_active_mute_rules(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""Test active MuteRule mutes findings with correct reason"""
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
new_callable=dict,
),
patch("api.compliance.PROWLER_CHECKS", new_callable=dict),
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
# Create active MuteRule with specific finding UIDs
mute_rule_reason = "Accepted risk - production exception"
finding_uid_1 = "finding_to_mute_1"
finding_uid_2 = "finding_to_mute_2"
MuteRule.objects.create(
tenant_id=tenant_id,
name="Production Exception Rule",
reason=mute_rule_reason,
enabled=True,
finding_uids=[finding_uid_1, finding_uid_2],
)
# Mock findings: one FAIL and one PASS, both should be muted
muted_fail_finding = MagicMock()
muted_fail_finding.uid = finding_uid_1
muted_fail_finding.status = StatusChoices.FAIL
muted_fail_finding.status_extended = "muted fail"
muted_fail_finding.severity = Severity.high
muted_fail_finding.check_id = "muted_fail_check"
muted_fail_finding.get_metadata.return_value = {"key": "value"}
muted_fail_finding.resource_uid = "resource_uid_1"
muted_fail_finding.resource_name = "resource_1"
muted_fail_finding.region = "us-east-1"
muted_fail_finding.service_name = "ec2"
muted_fail_finding.resource_type = "instance"
muted_fail_finding.resource_tags = {}
muted_fail_finding.muted = False
muted_fail_finding.raw = {}
muted_fail_finding.resource_metadata = {}
muted_fail_finding.resource_details = {}
muted_fail_finding.partition = "aws"
muted_fail_finding.compliance = {}
muted_pass_finding = MagicMock()
muted_pass_finding.uid = finding_uid_2
muted_pass_finding.status = StatusChoices.PASS
muted_pass_finding.status_extended = "muted pass"
muted_pass_finding.severity = Severity.medium
muted_pass_finding.check_id = "muted_pass_check"
muted_pass_finding.get_metadata.return_value = {"key": "value"}
muted_pass_finding.resource_uid = "resource_uid_2"
muted_pass_finding.resource_name = "resource_2"
muted_pass_finding.region = "us-east-1"
muted_pass_finding.service_name = "s3"
muted_pass_finding.resource_type = "bucket"
muted_pass_finding.resource_tags = {}
muted_pass_finding.muted = False
muted_pass_finding.raw = {}
muted_pass_finding.resource_metadata = {}
muted_pass_finding.resource_details = {}
muted_pass_finding.partition = "aws"
muted_pass_finding.compliance = {}
# Mock the ProwlerScan instance
mock_prowler_scan_instance = MagicMock()
mock_prowler_scan_instance.scan.return_value = [
(100, [muted_fail_finding, muted_pass_finding])
]
mock_prowler_scan_class.return_value = mock_prowler_scan_instance
# Mock prowler_provider
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
# Call the function under test
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify findings are muted with correct reason
fail_finding_db = Finding.objects.get(uid=finding_uid_1)
pass_finding_db = Finding.objects.get(uid=finding_uid_2)
assert fail_finding_db.muted
assert fail_finding_db.muted_reason == mute_rule_reason
assert fail_finding_db.muted_at is not None
assert pass_finding_db.muted
assert pass_finding_db.muted_reason == mute_rule_reason
assert pass_finding_db.muted_at is not None
# Verify failed_findings_count is 0 for muted FAIL finding
resource_1 = Resource.objects.get(uid="resource_uid_1")
assert resource_1.failed_findings_count == 0
def test_perform_prowler_scan_with_inactive_mute_rules(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""Test inactive MuteRule does not mute findings"""
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
new_callable=dict,
),
patch("api.compliance.PROWLER_CHECKS", new_callable=dict),
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
# Create inactive MuteRule
finding_uid = "finding_inactive_rule"
MuteRule.objects.create(
tenant_id=tenant_id,
name="Inactive Rule",
reason="Should not apply",
enabled=False,
finding_uids=[finding_uid],
)
# Mock FAIL finding
fail_finding = MagicMock()
fail_finding.uid = finding_uid
fail_finding.status = StatusChoices.FAIL
fail_finding.status_extended = "test fail"
fail_finding.severity = Severity.high
fail_finding.check_id = "fail_check"
fail_finding.get_metadata.return_value = {"key": "value"}
fail_finding.resource_uid = "resource_uid_inactive"
fail_finding.resource_name = "resource_inactive"
fail_finding.region = "us-east-1"
fail_finding.service_name = "ec2"
fail_finding.resource_type = "instance"
fail_finding.resource_tags = {}
fail_finding.muted = False
fail_finding.raw = {}
fail_finding.resource_metadata = {}
fail_finding.resource_details = {}
fail_finding.partition = "aws"
fail_finding.compliance = {}
# Mock the ProwlerScan instance
mock_prowler_scan_instance = MagicMock()
mock_prowler_scan_instance.scan.return_value = [(100, [fail_finding])]
mock_prowler_scan_class.return_value = mock_prowler_scan_instance
# Mock prowler_provider
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
# Call the function under test
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify finding is NOT muted
finding_db = Finding.objects.get(uid=finding_uid)
assert not finding_db.muted
assert finding_db.muted_reason is None
assert finding_db.muted_at is None
# Verify failed_findings_count increments for FAIL finding
resource = Resource.objects.get(uid="resource_uid_inactive")
assert resource.failed_findings_count == 1
def test_perform_prowler_scan_mutelist_overrides_mute_rules(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""Test mutelist processor takes precedence over MuteRule"""
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
new_callable=dict,
),
patch("api.compliance.PROWLER_CHECKS", new_callable=dict),
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
# Create active MuteRule
finding_uid = "finding_both_rules"
MuteRule.objects.create(
tenant_id=tenant_id,
name="Manual Mute Rule",
reason="Muted by manual rule",
enabled=True,
finding_uids=[finding_uid],
)
# Mock finding with mutelist processor muted=True
muted_finding = MagicMock()
muted_finding.uid = finding_uid
muted_finding.status = StatusChoices.FAIL
muted_finding.status_extended = "test"
muted_finding.severity = Severity.high
muted_finding.check_id = "test_check"
muted_finding.get_metadata.return_value = {"key": "value"}
muted_finding.resource_uid = "resource_both"
muted_finding.resource_name = "resource_both"
muted_finding.region = "us-east-1"
muted_finding.service_name = "ec2"
muted_finding.resource_type = "instance"
muted_finding.resource_tags = {}
muted_finding.muted = True
muted_finding.raw = {}
muted_finding.resource_metadata = {}
muted_finding.resource_details = {}
muted_finding.partition = "aws"
muted_finding.compliance = {}
# Mock the ProwlerScan instance
mock_prowler_scan_instance = MagicMock()
mock_prowler_scan_instance.scan.return_value = [(100, [muted_finding])]
mock_prowler_scan_class.return_value = mock_prowler_scan_instance
# Mock prowler_provider
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
# Call the function under test
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify mutelist reason takes precedence
finding_db = Finding.objects.get(uid=finding_uid)
assert finding_db.muted
assert finding_db.muted_reason == "Muted by mutelist"
assert finding_db.muted_at is not None
# Verify failed_findings_count is 0
resource = Resource.objects.get(uid="resource_both")
assert resource.failed_findings_count == 0
def test_perform_prowler_scan_mute_rules_multiple_findings(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""Test MuteRule with multiple finding UIDs mutes all findings"""
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
new_callable=dict,
),
patch("api.compliance.PROWLER_CHECKS", new_callable=dict),
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
# Create MuteRule with multiple finding UIDs
mute_rule_reason = "Bulk exception for dev environment"
finding_uids = [
"bulk_finding_1",
"bulk_finding_2",
"bulk_finding_3",
"bulk_finding_4",
]
MuteRule.objects.create(
tenant_id=tenant_id,
name="Bulk Mute Rule",
reason=mute_rule_reason,
enabled=True,
finding_uids=finding_uids,
)
# Mock multiple findings with mixed statuses
findings = []
for i, uid in enumerate(finding_uids):
finding = MagicMock()
finding.uid = uid
finding.status = (
StatusChoices.FAIL if i % 2 == 0 else StatusChoices.PASS
)
finding.status_extended = f"test {i}"
finding.severity = Severity.medium
finding.check_id = f"check_{i}"
finding.get_metadata.return_value = {"key": f"value_{i}"}
finding.resource_uid = f"resource_bulk_{i}"
finding.resource_name = f"resource_{i}"
finding.region = "us-west-2"
finding.service_name = "lambda"
finding.resource_type = "function"
finding.resource_tags = {}
finding.muted = False
finding.raw = {}
finding.resource_metadata = {}
finding.resource_details = {}
finding.partition = "aws"
finding.compliance = {}
findings.append(finding)
# Mock the ProwlerScan instance
mock_prowler_scan_instance = MagicMock()
mock_prowler_scan_instance.scan.return_value = [(100, findings)]
mock_prowler_scan_class.return_value = mock_prowler_scan_instance
# Mock prowler_provider
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = ["us-west-2"]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
# Call the function under test
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify all findings are muted with same reason
for uid in finding_uids:
finding_db = Finding.objects.get(uid=uid)
assert finding_db.muted
assert finding_db.muted_reason == mute_rule_reason
assert finding_db.muted_at is not None
# Verify all resources have failed_findings_count = 0
for i in range(len(finding_uids)):
resource = Resource.objects.get(uid=f"resource_bulk_{i}")
assert resource.failed_findings_count == 0
def test_perform_prowler_scan_mute_rules_error_handling(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""Test scan continues when MuteRule loading fails"""
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
new_callable=dict,
),
patch("api.compliance.PROWLER_CHECKS", new_callable=dict),
patch("api.models.MuteRule.objects.filter") as mock_mute_rule_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
# Mock MuteRule.objects.filter to raise exception
mock_mute_rule_filter.side_effect = Exception("Database error")
# Mock finding
finding = MagicMock()
finding.uid = "finding_error_handling"
finding.status = StatusChoices.FAIL
finding.status_extended = "test"
finding.severity = Severity.high
finding.check_id = "test_check"
finding.get_metadata.return_value = {"key": "value"}
finding.resource_uid = "resource_error"
finding.resource_name = "resource_error"
finding.region = "us-east-1"
finding.service_name = "ec2"
finding.resource_type = "instance"
finding.resource_tags = {}
finding.muted = False
finding.raw = {}
finding.resource_metadata = {}
finding.resource_details = {}
finding.partition = "aws"
finding.compliance = {}
# Mock the ProwlerScan instance
mock_prowler_scan_instance = MagicMock()
mock_prowler_scan_instance.scan.return_value = [(100, [finding])]
mock_prowler_scan_class.return_value = mock_prowler_scan_instance
# Mock prowler_provider
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
# Call the function under test - should not raise
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify scan completed successfully
scan.refresh_from_db()
assert scan.state == StateChoices.COMPLETED
# Verify finding is not muted (mute_rules_cache was empty dict)
finding_db = Finding.objects.get(uid="finding_error_handling")
assert not finding_db.muted
assert finding_db.muted_reason is None
# Verify failed_findings_count increments
resource = Resource.objects.get(uid="resource_error")
assert resource.failed_findings_count == 1
def test_perform_prowler_scan_muted_at_timestamp(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""Test muted_at timestamp is set correctly for muted findings"""
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
new_callable=dict,
),
patch("api.compliance.PROWLER_CHECKS", new_callable=dict),
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
# Create active MuteRule
finding_uid = "finding_timestamp_test"
MuteRule.objects.create(
tenant_id=tenant_id,
name="Timestamp Test Rule",
reason="Testing timestamp",
enabled=True,
finding_uids=[finding_uid],
)
# Mock finding
finding = MagicMock()
finding.uid = finding_uid
finding.status = StatusChoices.FAIL
finding.status_extended = "test"
finding.severity = Severity.high
finding.check_id = "test_check"
finding.get_metadata.return_value = {"key": "value"}
finding.resource_uid = "resource_timestamp"
finding.resource_name = "resource_timestamp"
finding.region = "us-east-1"
finding.service_name = "ec2"
finding.resource_type = "instance"
finding.resource_tags = {}
finding.muted = False
finding.raw = {}
finding.resource_metadata = {}
finding.resource_details = {}
finding.partition = "aws"
finding.compliance = {}
# Mock the ProwlerScan instance
mock_prowler_scan_instance = MagicMock()
mock_prowler_scan_instance.scan.return_value = [(100, [finding])]
mock_prowler_scan_class.return_value = mock_prowler_scan_instance
# Mock prowler_provider
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
# Capture time before and after scan
before_scan = datetime.now(timezone.utc)
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
after_scan = datetime.now(timezone.utc)
# Verify muted_at is within the scan time window
finding_db = Finding.objects.get(uid=finding_uid)
assert finding_db.muted
assert finding_db.muted_at is not None
assert before_scan <= finding_db.muted_at <= after_scan
# TODO Add tests for aggregations
+1 -369
View File
@@ -1,24 +1,16 @@
import uuid
from unittest.mock import MagicMock, patch
import openai
import pytest
from botocore.exceptions import ClientError
from tasks.tasks import (
_perform_scan_complete_tasks,
check_integrations_task,
check_lighthouse_provider_connection_task,
generate_outputs_task,
refresh_lighthouse_provider_models_task,
s3_integration_task,
security_hub_integration_task,
)
from api.models import (
Integration,
LighthouseProviderConfiguration,
LighthouseProviderModels,
)
from api.models import Integration
# TODO Move this to outputs/reports jobs
@@ -1105,363 +1097,3 @@ class TestCheckIntegrationsTask:
assert result is False
mock_upload.assert_called_once_with(self.tenant_id, self.provider_id, scan_id)
@pytest.mark.django_db
class TestCheckLighthouseProviderConnectionTask:
def setup_method(self):
self.tenant_id = str(uuid.uuid4())
@pytest.mark.parametrize(
"provider_type,credentials,base_url,expected_result",
[
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI,
{"api_key": "sk-test123"},
None,
{"connected": True, "error": None},
),
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
{"api_key": "sk-test123"},
"https://openrouter.ai/api/v1",
{"connected": True, "error": None},
),
(
LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
{
"access_key_id": "AKIA123",
"secret_access_key": "secret",
"region": "us-east-1",
},
None,
{"connected": True, "error": None},
),
],
)
def test_check_connection_success_all_providers(
self, tenants_fixture, provider_type, credentials, base_url, expected_result
):
"""Test successful connection check for all provider types."""
# Create provider configuration
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=provider_type,
base_url=base_url,
is_active=False,
)
provider_cfg.credentials_decoded = credentials
provider_cfg.save()
# Mock the appropriate API calls
with (
patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai,
patch("tasks.jobs.lighthouse_providers.boto3.client") as mock_boto3,
):
mock_client = MagicMock()
mock_client.models.list.return_value = MagicMock()
mock_client.list_foundation_models.return_value = {}
mock_openai.return_value = mock_client
mock_boto3.return_value = mock_client
# Execute
result = check_lighthouse_provider_connection_task(
provider_config_id=str(provider_cfg.id),
tenant_id=str(tenants_fixture[0].id),
)
# Assert
assert result == expected_result
provider_cfg.refresh_from_db()
assert provider_cfg.is_active is True
@pytest.mark.parametrize(
"provider_type,credentials,base_url,exception_to_raise",
[
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI,
{"api_key": "sk-invalid"},
None,
openai.AuthenticationError(
"Invalid API key", response=MagicMock(), body=None
),
),
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
{"api_key": "sk-invalid"},
"https://openrouter.ai/api/v1",
openai.APIConnectionError(request=MagicMock()),
),
(
LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
{
"access_key_id": "AKIA123",
"secret_access_key": "secret",
"region": "us-east-1",
},
None,
ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access Denied"}},
"list_foundation_models",
),
),
],
)
def test_check_connection_api_failure(
self,
tenants_fixture,
provider_type,
credentials,
base_url,
exception_to_raise,
):
"""Test connection check when API calls fail."""
# Create provider configuration
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=provider_type,
base_url=base_url,
is_active=True,
)
provider_cfg.credentials_decoded = credentials
provider_cfg.save()
# Mock the API to raise exception
with (
patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai,
patch("tasks.jobs.lighthouse_providers.boto3.client") as mock_boto3,
):
mock_client = MagicMock()
if (
provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK
):
mock_client.list_foundation_models.side_effect = exception_to_raise
mock_boto3.return_value = mock_client
else:
mock_client.models.list.side_effect = exception_to_raise
mock_openai.return_value = mock_client
# Execute
result = check_lighthouse_provider_connection_task(
provider_config_id=str(provider_cfg.id),
tenant_id=str(tenants_fixture[0].id),
)
# Assert
assert result["connected"] is False
assert result["error"] is not None
provider_cfg.refresh_from_db()
assert provider_cfg.is_active is False
def test_check_connection_updates_active_status(self, tenants_fixture):
"""Test that connection check toggles is_active from True to False on failure."""
# Create provider with is_active=True
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI,
base_url=None,
is_active=True,
)
provider_cfg.credentials_decoded = {"api_key": "sk-test123"}
provider_cfg.save()
# Mock API to fail
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
mock_client = MagicMock()
mock_client.models.list.side_effect = openai.AuthenticationError(
"Invalid", response=MagicMock(), body=None
)
mock_openai.return_value = mock_client
# Execute
result = check_lighthouse_provider_connection_task(
provider_config_id=str(provider_cfg.id),
tenant_id=str(tenants_fixture[0].id),
)
# Assert status changed
assert result["connected"] is False
provider_cfg.refresh_from_db()
assert provider_cfg.is_active is False
def test_check_connection_provider_does_not_exist(self, tenants_fixture):
"""Test that checking non-existent provider raises DoesNotExist."""
non_existent_id = str(uuid.uuid4())
with pytest.raises(LighthouseProviderConfiguration.DoesNotExist):
check_lighthouse_provider_connection_task(
provider_config_id=non_existent_id,
tenant_id=str(tenants_fixture[0].id),
)
@pytest.mark.django_db
class TestRefreshLighthouseProviderModelsTask:
def setup_method(self):
self.tenant_id = str(uuid.uuid4())
@pytest.mark.parametrize(
"provider_type,credentials,base_url,mock_models,expected_count",
[
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI,
{"api_key": "sk-test123"},
None,
{"gpt-5": "gpt-5", "gpt-4o": "gpt-4o"},
2,
),
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
{"api_key": "sk-test123"},
"https://openrouter.ai/api/v1",
{"model-1": "Model One", "model-2": "Model Two"},
2,
),
(
LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
{
"access_key_id": "AKIA123",
"secret_access_key": "secret",
"region": "us-east-1",
},
None,
{"openai.gpt-oss-120b-1:0": "gpt-oss-120b"},
1,
),
],
)
def test_refresh_models_create_new(
self,
tenants_fixture,
provider_type,
credentials,
base_url,
mock_models,
expected_count,
):
"""Test creating new models for all provider types."""
# Create provider configuration
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=provider_type,
base_url=base_url,
is_active=True,
)
provider_cfg.credentials_decoded = credentials
provider_cfg.save()
# Mock the fetch functions
with (
patch(
"tasks.jobs.lighthouse_providers._fetch_openai_models",
return_value=mock_models,
),
patch(
"tasks.jobs.lighthouse_providers._fetch_openai_compatible_models",
return_value=mock_models,
),
patch(
"tasks.jobs.lighthouse_providers._fetch_bedrock_models",
return_value=mock_models,
),
):
# Execute
result = refresh_lighthouse_provider_models_task(
provider_config_id=str(provider_cfg.id),
tenant_id=str(tenants_fixture[0].id),
)
# Assert
assert result["created"] == expected_count
assert result["updated"] == 0
assert result["deleted"] == 0
assert (
LighthouseProviderModels.objects.filter(
provider_configuration=provider_cfg
).count()
== expected_count
)
def test_refresh_models_mixed_operations(self, tenants_fixture):
"""Test mixed create, update, and delete operations."""
# Create provider configuration
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI,
base_url=None,
is_active=True,
)
provider_cfg.credentials_decoded = {"api_key": "sk-test123"}
provider_cfg.save()
# Create 2 existing models (A, B)
LighthouseProviderModels.objects.create(
tenant_id=tenants_fixture[0].id,
provider_configuration=provider_cfg,
model_id="model-a",
model_name="Model A",
)
LighthouseProviderModels.objects.create(
tenant_id=tenants_fixture[0].id,
provider_configuration=provider_cfg,
model_id="model-b",
model_name="Model B",
)
# Mock API to return models B (existing), C (new) - A will be deleted
mock_models = {"model-b": "Model B", "model-c": "Model C"}
with patch(
"tasks.jobs.lighthouse_providers._fetch_openai_models",
return_value=mock_models,
):
# Execute
result = refresh_lighthouse_provider_models_task(
provider_config_id=str(provider_cfg.id),
tenant_id=str(tenants_fixture[0].id),
)
# Assert
assert result["created"] == 1 # model-c created
assert result["updated"] == 1 # model-b updated
assert result["deleted"] == 1 # model-a deleted
# Verify only B and C exist
remaining_models = LighthouseProviderModels.objects.filter(
provider_configuration=provider_cfg
)
assert remaining_models.count() == 2
assert set(remaining_models.values_list("model_id", flat=True)) == {
"model-b",
"model-c",
}
def test_refresh_models_api_exception(self, tenants_fixture):
"""Test refresh when API raises an exception."""
# Create provider configuration
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI,
base_url=None,
is_active=True,
)
provider_cfg.credentials_decoded = {"api_key": "sk-test123"}
provider_cfg.save()
# Mock fetch to raise exception
with patch(
"tasks.jobs.lighthouse_providers._fetch_openai_models",
side_effect=openai.APIError("API Error", request=MagicMock(), body=None),
):
# Execute
result = refresh_lighthouse_provider_models_task(
provider_config_id=str(provider_cfg.id),
tenant_id=str(tenants_fixture[0].id),
)
# Assert
assert result["created"] == 0
assert result["updated"] == 0
assert result["deleted"] == 0
assert "error" in result
assert result["error"] is not None
+335
View File
@@ -0,0 +1,335 @@
# Prowler API Reference Documentation
This directory contains the API reference documentation for Prowler Cloud, integrated with Mintlify.
## Structure
```
api-reference/
├── README.md # This file
├── openapi.yaml # OpenAPI specification (auto-synced from api/src/backend/api/specs/v1.yaml)
├── introduction.mdx # API introduction and getting started guide
│
├── tokens/ # Authentication endpoints
│ ├── create.mdx # Create JWT token
│ ├── refresh.mdx # Refresh JWT token
│ └── switch.mdx # Switch tenant context
│
├── api-keys/ # API Keys endpoints
│ ├── list.mdx
│ ├── create.mdx
│ ├── retrieve.mdx
│ ├── update.mdx
│ └── revoke.mdx
│
├── users/ # User endpoints
│ └── me.mdx # Get current user
│
├── tenants/ # Tenant management
│ ├── list.mdx
│ ├── invitations-list.mdx
│ └── invitations-create.mdx
│
├── invitations/ # Invitation endpoints
│ └── accept.mdx
│
├── providers/ # Cloud provider management
│ ├── list.mdx
│ ├── create.mdx
│ ├── retrieve.mdx
│ ├── update.mdx
│ ├── delete.mdx
│ └── check-connection.mdx
│
├── scans/ # Security scan endpoints
│ ├── list.mdx
│ ├── retrieve.mdx
│ ├── compliance.mdx
│ ├── report.mdx
│ └── threatscore.mdx
│
├── findings/ # Security findings endpoints
│ ├── list.mdx
│ ├── retrieve.mdx
│ ├── latest.mdx
│ ├── services-regions.mdx
│ ├── metadata.mdx
│ └── metadata-latest.mdx
│
├── resources/ # Cloud resource endpoints
│ ├── list.mdx
│ ├── retrieve.mdx
│ ├── latest.mdx
│ ├── metadata.mdx
│ └── metadata-latest.mdx
│
├── compliance/ # Compliance framework endpoints
│ ├── list.mdx
│ ├── requirements.mdx
│ ├── attributes.mdx
│ └── metadata.mdx
│
├── overviews/ # Dashboard overview endpoints
│ ├── findings.mdx
│ ├── findings-severity.mdx
│ ├── providers.mdx
│ ├── providers-count.mdx
│ └── services.mdx
│
├── integrations/ # External integrations
│ ├── list.mdx
│ ├── create.mdx
│ ├── retrieve.mdx
│ ├── update.mdx
│ ├── delete.mdx
│ ├── check-connection.mdx
│ └── jira-dispatch.mdx
│
├── lighthouse/ # Lighthouse AI endpoints
│ ├── configuration-get.mdx
│ ├── configuration-update.mdx
│ ├── providers-list.mdx
│ ├── providers-create.mdx
│ └── models-list.mdx
│
├── processors/ # Finding processors (mutelists)
│ ├── list.mdx
│ └── create.mdx
│
├── schedules/ # Automated scan scheduling
│ └── daily.mdx
│
└── tasks/ # Asynchronous task management
├── list.mdx
└── retrieve.mdx
```
**Total: 131 endpoint documentation files organized in 18 groups**
✅ **100% API Coverage** - All 123 operation IDs documented
## How It Works
The API documentation uses Mintlify's native OpenAPI support to automatically generate interactive API documentation from the OpenAPI specification file.
### Components
1. **openapi.yaml**: The source of truth for API endpoints, copied from `api/src/backend/api/specs/v1.yaml`
2. **MDX files**: Enhanced documentation for each endpoint with examples, tips, and additional context
3. **docs.json**: Mintlify configuration that references the OpenAPI spec
## Updating the Documentation
### When the API Spec Changes
When you update the OpenAPI specification in `api/src/backend/api/specs/v1.yaml`, you need to sync it to the docs:
```bash
# From the root of the repository
cp api/src/backend/api/specs/v1.yaml docs/api-reference/openapi.yaml
```
Consider automating this with a pre-commit hook or CI/CD pipeline.
### Adding New Endpoints
To document a new API endpoint:
1. **Update the OpenAPI spec** in `api/src/backend/api/specs/v1.yaml`
2. **Sync to docs**: Copy the spec to `docs/api-reference/openapi.yaml`
3. **Create MDX file**: Create a new `.mdx` file in the appropriate directory
4. **Update navigation**: Add the new page to `docs/docs.json` in the API Reference tab
#### MDX File Template
```mdx
---
title: "Endpoint Title"
api: "METHOD /api/v1/endpoint"
description: "Brief description of what this endpoint does."
---
Detailed description of the endpoint.
## Path Parameters
- `param` (required/optional) - Description
## Query Parameters
- `param` (required/optional) - Description
## Request Body
```json
{
"example": "request"
}
```
## Example Request
```bash
curl -X METHOD "https://api.prowler.com/api/v1/endpoint" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Description of the response.
```
### Testing Changes Locally
To preview documentation changes locally:
```bash
cd docs
mintlify dev
```
Then open http://localhost:3000 in your browser.
## Mintlify Configuration
The API documentation is configured in `docs/docs.json`:
- **openapi**: Points to the OpenAPI spec file
- **api.baseUrl**: The base URL for the API
- **api.auth.method**: Authentication method (bearer token)
- **api.playground.mode**: Interactive API playground mode
## API Endpoint Groups
### Core Endpoints
- **Authentication (3)**: JWT token management and tenant switching
- **API Keys (5)**: Programmatic API access management
- **Users (1)**: User profile and information
- **Tenants (3)**: Organization and invitation management
### Cloud Infrastructure
- **Providers (6)**: Cloud provider (AWS, Azure, GCP, etc.) configuration
- **Scans (5)**: Security scan execution and results
- **Findings (6)**: Security findings and vulnerabilities
- **Resources (5)**: Cloud resource inventory and metadata
### Compliance & Reporting
- **Compliance (4)**: Compliance framework assessments (CIS, PCI-DSS, etc.)
- **Overviews (5)**: Dashboard aggregated statistics
### Integrations & Automation
- **Integrations (7)**: External service integrations (S3, Security Hub, JIRA, Slack)
- **Lighthouse AI (5)**: AI-powered security insights configuration
- **Processors (2)**: Finding processors and mutelists
- **Schedules (1)**: Automated scan scheduling
### System
- **Tasks (2)**: Asynchronous task monitoring
## Best Practices
1. **Keep OpenAPI spec up to date**: Always update the source spec first, then sync to docs
2. **Add examples**: Include real-world examples in MDX files
3. **Use callouts**: Leverage Mintlify components like `<Note>`, `<Tip>`, `<Warning>` for important information
4. **Test playground**: Verify that the interactive API playground works for each endpoint
5. **Document filters**: For list endpoints, clearly document all available filters
6. **Include rate limits**: Document any rate limiting or pagination requirements
7. **Group related endpoints**: Keep related endpoints in the same directory
8. **Use consistent naming**: Follow the pattern `action.mdx` (e.g., `list.mdx`, `create.mdx`, `retrieve.mdx`)
## Resources
- [Mintlify OpenAPI Guide](https://mintlify.com/docs/api-playground/openapi-support)
- [Mintlify Components](https://mintlify.com/docs/content/components)
- [JSON:API Specification](https://jsonapi.org/)
- [Prowler Cloud API](https://api.prowler.com/api/v1/docs)
## Syncing OpenAPI Spec
The OpenAPI specification is maintained in the API repository and should be synced regularly:
```bash
# Using the provided sync script
cd docs
./sync-api-spec.sh
# Or manually
cp ../api/src/backend/api/specs/v1.yaml ./api-reference/openapi.yaml
```
The `sync-api-spec.sh` script automates this process and can be integrated into your CI/CD pipeline.
## Automation 🤖
**NEW**: Documentation can now be auto-generated from the OpenAPI specification!
### Quick Start with Automation
```bash
cd docs
# 1. Sync latest OpenAPI spec from GitHub
./sync-api-spec.sh
# 2. Generate MDX files automatically
python3 generate-api-docs.py
# 3. Test locally
mintlify dev
```
### Available Scripts
1. **`sync-api-spec.sh`** - Downloads latest OpenAPI spec from GitHub
2. **`generate-api-docs.py`** - Generates/updates MDX files from OpenAPI spec
### Features
- ✅ Auto-generate MDX files from OpenAPI spec
- ✅ Update existing or create new endpoints
- ✅ Dry-run mode to preview changes
- ✅ Proper directory structure and naming
- ✅ Frontmatter generation (title, API path, description)
- ✅ Parameter documentation extraction
- ✅ Request/response examples
### Documentation
See **[AUTOMATION.md](./AUTOMATION.md)** for complete automation guide including:
- Detailed usage instructions
- CI/CD integration examples
- Troubleshooting
- Best practices
## Recent Updates
### January 2025 - Complete API Documentation & Automation
#### Documentation Expansion
- ✅ **100% Coverage**: All 123 operation IDs documented (131 MDX files)
- ✅ **18 Endpoint Groups**: Complete organization
- ✅ **New Groups Added**:
- Provider Secrets (5 endpoints)
- Provider Groups (8 endpoints)
- Roles (8 endpoints)
- SAML Configuration (5 endpoints)
- Lighthouse AI expanded (17 endpoints with nested structure)
- Processors (5 endpoints)
- Tasks (3 endpoints)
- Compliance Overview (4 endpoints)
- Overviews (5 endpoints)
#### Automation Implementation
- ✅ **Auto-generation script**: `generate-api-docs.py`
- ✅ **Sync script**: `sync-api-spec.sh`
- ✅ **Complete automation guide**: AUTOMATION.md
- ✅ **CI/CD ready**: GitHub Actions example included
#### Quality Improvements
- ✅ Field name corrections verified against OpenAPI spec
- ✅ JSON:API compliance in all examples
- ✅ Proper nested structures (Provider Secrets, Tenant Memberships/Invitations)
- ✅ Comprehensive documentation files (CORRECTIONS.md, VERIFICATION.md, IMPROVEMENTS.md)
**Total coverage increased from 14 to 131 documented endpoints (843% increase)**
+182
View File
@@ -0,0 +1,182 @@
---
title: "Create API Key"
api: "POST /api/v1/api-keys"
description: "Create a new API key for the tenant."
---
Create a new API key for programmatic access to the Prowler API. The API key will be returned in the response and should be stored securely as it cannot be retrieved later.
## Request Body
The request must follow the JSON:API specification format:
```json
{
"data": {
"type": "api-keys",
"attributes": {
"name": "string",
"expires_at": "2024-12-31T23:59:59Z"
}
}
}
```
### Attributes
- `name` (required) - A descriptive name for the API key
- `expires_at` (optional) - Expiration date for the API key in ISO 8601 format
## Example Request
```bash
curl -X POST "https://api.prowler.com/api/v1/api-keys" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "api-keys",
"attributes": {
"name": "Production API Key",
"expires_at": "2025-12-31T23:59:59Z"
}
}
}'
```
## Response
### Success Response (201 Created)
Returns the created API key **including the full key value** (only shown once):
```json
{
"data": {
"type": "api-keys",
"id": "api-key-uuid",
"attributes": {
"name": "Production API Key",
"prefix": "pk_live_abc123",
"api_key": "pk_live_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": false,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": null
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
}
```
### Response Fields
- `id` - Unique UUID for the API key record
- `name` - Descriptive name you provided (3-100 characters)
- `prefix` - First characters of the key (for identification, read-only)
- `api_key` - **Full API key value** (only returned on creation, read-only)
- `expires_at` - Expiration date in ISO 8601 format (optional)
- `revoked` - Whether key has been revoked (always false on creation, read-only)
- `inserted_at` - When key was created (read-only)
- `last_used_at` - Last usage timestamp (null initially, read-only)
### Error Responses
**400 Bad Request** - Invalid expiration date
```json
{
"errors": [
{
"status": "400",
"title": "Invalid Expiration Date",
"detail": "Expiration date must be in the future"
}
]
}
```
**422 Unprocessable Entity** - Missing required fields
```json
{
"errors": [
{
"status": "422",
"title": "Validation Error",
"detail": "Name is required",
"source": {
"pointer": "/data/attributes/name"
}
}
]
}
```
**429 Too Many Requests** - API key limit reached
```json
{
"errors": [
{
"status": "429",
"title": "API Key Limit Exceeded",
"detail": "Maximum of 10 active API keys per tenant. Revoke unused keys to create new ones."
}
]
}
```
## Usage Example
After creating the API key, use it to authenticate API requests:
```bash
# Save the API key
API_KEY="pk_live_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
# Use it in requests
curl -X GET "https://api.prowler.com/api/v1/providers" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Security Best Practices
1. **Store Securely**: Save the API key in a secure location immediately (password manager, secrets vault, environment variables)
2. **Never Commit**: Do not commit API keys to version control systems
3. **Use Environment Variables**: Store keys in environment variables, not in code
4. **Set Expiration**: Always set an expiration date for API keys (recommended: 90 days)
5. **Rotate Regularly**: Rotate API keys every 90 days
6. **Limit Scope**: Use separate API keys for different environments (dev, staging, production)
7. **Monitor Usage**: Regularly check `last_used_at` to identify unused keys
8. **Revoke Unused**: Revoke API keys that are no longer needed
## Naming Conventions
Use descriptive names that include:
- **Environment**: Production, Staging, Development
- **Purpose**: CI/CD, Dashboard, Integration
- **Owner/Team**: Security Team, DevOps, Monitoring
Examples:
- "Production CI/CD Pipeline"
- "Staging Dashboard API Access"
- "Security Team Automation"
- "JIRA Integration - Production"
<Warning>
**CRITICAL:** The API key is only shown once in the creation response. It cannot be retrieved later. If you lose the key, you must revoke it and create a new one. Store it securely immediately after creation.
</Warning>
<Note>
API keys have the same permissions as the user who created them. For automated systems, consider creating a dedicated service account with limited permissions.
</Note>
<Tip>
**Automation Tip:** API keys are ideal for CI/CD pipelines, scheduled scans, and integrations. Unlike JWT tokens, they don't expire hourly and don't require refresh logic.
</Tip>
+124
View File
@@ -0,0 +1,124 @@
---
title: "List API Keys"
api: "GET /api/v1/api-keys"
description: "Retrieve a list of API keys for the tenant, with filtering support."
---
Retrieve a list of all API keys associated with your tenant. This endpoint supports various filtering options to help you find specific keys.
## Query Parameters
### Filtering
- `filter[name]` - Filter by exact API key name
- `filter[name__icontains]` - Filter by API key name (case-insensitive)
- `filter[prefix]` - Filter by API key prefix
- `filter[revoked]` - Filter by revocation status (boolean)
- `filter[expires_at]`, `filter[expires_at__gte]`, `filter[expires_at__lte]` - Filter by expiration date
- `filter[inserted_at]`, `filter[inserted_at__gte]`, `filter[inserted_at__lte]` - Filter by creation date
- `filter[search]` - General search term
### Pagination
- `page[number]` - Page number to retrieve
- `page[size]` - Number of results per page
### Sorting
- `sort` - Field to sort by. Available options: `name`, `-name`, `prefix`, `-prefix`, `revoked`, `-revoked`, `inserted_at`, `-inserted_at`, `expires_at`, `-expires_at`
### Field Selection
- `fields[api-keys]` - Specify which fields to return: `name`, `prefix`, `expires_at`, `revoked`, `inserted_at`, `last_used_at`, `entity`
### Include Related Resources
- `include` - Include related resources. Available: `entity`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/api-keys?page[size]=10&sort=-inserted_at" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns a paginated list of API keys with their metadata:
```json
{
"data": [
{
"type": "api-keys",
"id": "api-key-uuid-1",
"attributes": {
"name": "Production CI/CD Pipeline",
"prefix": "pk_live_abc123",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": false,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:22:00Z"
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
},
{
"type": "api-keys",
"id": "api-key-uuid-2",
"attributes": {
"name": "Staging Dashboard API Access",
"prefix": "pk_live_def456",
"expires_at": null,
"revoked": false,
"inserted_at": "2024-01-10T08:15:00Z",
"last_used_at": null
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
],
"meta": {
"page": {
"number": 1,
"size": 20,
"total": 2,
"total_pages": 1
}
},
"links": {
"self": "https://api.prowler.com/api/v1/api-keys?page[number]=1",
"first": "https://api.prowler.com/api/v1/api-keys?page[number]=1",
"last": "https://api.prowler.com/api/v1/api-keys?page[number]=1"
}
}
```
### Response Fields
- `id` (UUID) - Unique identifier for the API key record
- `name` (string) - Descriptive name (3-100 characters)
- `prefix` (string, read-only) - First characters of the key for identification
- `expires_at` (datetime, nullable) - Expiration date in ISO 8601 format
- `revoked` (boolean, read-only) - Whether key has been revoked
- `inserted_at` (datetime, read-only) - When key was created
- `last_used_at` (datetime, nullable, read-only) - Last usage timestamp
<Note>
The full API key value is never returned after creation. Only the prefix is shown for identification purposes.
</Note>
+89
View File
@@ -0,0 +1,89 @@
---
title: "Retrieve API Key"
api: "GET /api/v1/api-keys/{id}"
description: "Fetch detailed information about a specific API key by its ID."
---
Retrieve detailed information about a specific API key. Note that the full API key value is never returned, only metadata about the key.
## Path Parameters
- `id` (required) - The UUID of the API key to retrieve
## Query Parameters
### Field Selection
- `fields[api-keys]` - Specify which fields to return: `name`, `prefix`, `expires_at`, `revoked`, `inserted_at`, `last_used_at`, `entity`
### Include Related Resources
- `include` - Include related resources. Available: `entity`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/api-keys/{id}" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns detailed metadata about the API key:
```json
{
"data": {
"type": "api-keys",
"id": "api-key-uuid",
"attributes": {
"name": "Production CI/CD Pipeline",
"prefix": "pk_live_abc123",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": false,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:22:00Z"
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
}
```
### Response Fields
- `id` (UUID) - Unique identifier for the API key record
- `name` (string) - Descriptive name (3-100 characters)
- `prefix` (string, read-only) - First characters of the key for identification
- `expires_at` (datetime, nullable) - Expiration date in ISO 8601 format
- `revoked` (boolean, read-only) - Whether key has been revoked
- `inserted_at` (datetime, read-only) - When key was created
- `last_used_at` (datetime, nullable, read-only) - Last usage timestamp
### Error Responses
**404 Not Found** - API key does not exist
```json
{
"errors": [
{
"status": "404",
"title": "Not Found",
"detail": "API key not found"
}
]
}
```
<Note>
The full API key value is never returned after creation. Only the prefix is shown for identification purposes.
</Note>
+86
View File
@@ -0,0 +1,86 @@
---
title: "Revoke API Key"
api: "DELETE /api/v1/api-keys/{id}/revoke"
description: "Revoke an API key by its ID. This action is irreversible."
---
Revoke an API key, preventing it from being used for further API requests. This action cannot be undone.
## Path Parameters
- `id` (required) - The UUID of the API key to revoke
## Example Request
```bash
curl -X DELETE "https://api.prowler.com/api/v1/api-keys/{id}/revoke" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns the API key with `revoked` status set to `true`:
```json
{
"data": {
"type": "api-keys",
"id": "api-key-uuid",
"attributes": {
"name": "Production CI/CD Pipeline",
"prefix": "pk_live_abc123",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": true,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:22:00Z"
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
}
```
### Error Responses
**404 Not Found** - API key does not exist
```json
{
"errors": [
{
"status": "404",
"title": "Not Found",
"detail": "API key not found"
}
]
}
```
**409 Conflict** - API key already revoked
```json
{
"errors": [
{
"status": "409",
"title": "Already Revoked",
"detail": "This API key has already been revoked"
}
]
}
```
<Warning>
Revoking an API key is permanent and cannot be undone. Any applications using this key will immediately lose access. The `revoked` field will be set to `true` and the key will no longer authenticate API requests.
</Warning>
<Tip>
Monitor the `last_used_at` field before revoking to ensure the key is no longer in active use.
</Tip>
+88
View File
@@ -0,0 +1,88 @@
---
title: "Update API Key"
api: "PATCH /api/v1/api-keys/{id}"
description: "Modify certain fields of an existing API key without affecting other settings."
---
Partially update an API key's properties. This endpoint allows you to modify the name or expiration date without affecting other settings.
## Path Parameters
- `id` (required) - The UUID of the API key to update
## Request Body
The request must follow the JSON:API specification format:
```json
{
"data": {
"type": "api-keys",
"id": "string",
"attributes": {
"name": "string",
"expires_at": "2024-12-31T23:59:59Z"
}
}
}
```
### Attributes
- `name` (optional, string) - Update the API key name (3-100 characters)
<Note>
Only the `name` field can be updated. Other fields like `expires_at`, `prefix`, and `revoked` are read-only and cannot be modified after creation.
</Note>
## Example Request
```bash
curl -X PATCH "https://api.prowler.com/api/v1/api-keys/{id}" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "api-keys",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"attributes": {
"name": "Updated API Key Name"
}
}
}'
```
## Response
### Success Response (200 OK)
Returns the updated API key with modified metadata:
```json
{
"data": {
"type": "api-keys",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"attributes": {
"name": "Updated API Key Name",
"prefix": "pk_live_abc123",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": false,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:22:00Z"
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
}
```
<Warning>
The full API key value is never returned in responses after creation. Only the prefix is shown for identification.
</Warning>
@@ -0,0 +1,7 @@
---
title: "Retrieve Compliance Attributes"
api: "GET /api/v1/compliance-overviews/attributes"
description: "Get compliance framework attributes."
---
Retrieve detailed attributes for compliance frameworks.
+17
View File
@@ -0,0 +1,17 @@
---
title: "List Compliance Overviews"
api: "GET /api/v1/compliance-overviews"
description: "Retrieve compliance framework coverage for scans."
---
Get compliance status across frameworks like CIS, PCI-DSS, HIPAA, etc.
## Query Parameters
- `filter[scan]` - Filter by scan UUID (required)
- `filter[framework]` - Filter by framework name
## Example
\`\`\`bash
curl "https://api.prowler.com/api/v1/compliance-overviews?filter[scan]=scan-uuid" \\
-H "Authorization: Bearer YOUR_API_KEY"
\`\`\`
@@ -0,0 +1,7 @@
---
title: "Retrieve Compliance Metadata"
api: "GET /api/v1/compliance-overviews/metadata"
description: "Get available compliance frameworks and metadata."
---
Fetch list of available compliance frameworks and their details.
@@ -0,0 +1,7 @@
---
title: "Retrieve Compliance Requirements"
api: "GET /api/v1/compliance-overviews/requirements"
description: "Get compliance requirements details."
---
Fetch specific requirements for compliance frameworks.
+109
View File
@@ -0,0 +1,109 @@
---
title: "List Latest Findings"
api: "GET /api/v1/findings/latest"
description: "Retrieve the latest findings from the most recent scans for each provider."
---
Retrieve the most recent findings from the latest scan for each provider in your account. This endpoint automatically filters to show only the latest scan results, making it ideal for dashboards and monitoring.
## Query Parameters
The endpoint supports the same filtering options as the regular findings list, except for the `inserted_at` filter (which is not required for this endpoint).
### Common Filters
**By Status:**
- `filter[status]`, `filter[status__in]` - Filter by status
- `filter[severity]`, `filter[severity__in]` - Filter by severity
- `filter[delta]`, `filter[delta__in]` - Filter by change status
- `filter[muted]` - Filter by mute status
**By Provider:**
- `filter[provider]`, `filter[provider__in]` - Filter by provider
- `filter[provider_type]`, `filter[provider_type__in]` - Filter by provider type
- `filter[provider_alias]`, `filter[provider_alias__icontains]` - Filter by provider alias
**By Resource:**
- `filter[resource_name]`, `filter[resource_name__icontains]` - Filter by resource name
- `filter[resource_type]`, `filter[resource_type__icontains]` - Filter by resource type
- `filter[region]`, `filter[region__in]` - Filter by region
- `filter[service]`, `filter[service__icontains]` - Filter by service
**By Check:**
- `filter[check_id]`, `filter[check_id__icontains]` - Filter by check ID
### Pagination & Sorting
- `page[number]`, `page[size]` - Pagination options
- `sort` - Sort by field
- `fields[findings]` - Select specific fields
- `include` - Include related resources
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/latest?filter[severity]=critical&filter[status]=FAIL&filter[provider_type]=aws" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns findings from the most recent scan for each provider:
```json
{
"data": [
{
"type": "findings",
"id": "finding-uuid",
"attributes": {
"uid": "prowler-aws-iam-password-policy-weak",
"check_id": "iam_password_policy_uppercase",
"status": "FAIL",
"status_extended": "IAM password policy does not require uppercase letters",
"severity": "medium",
"muted": false,
"muted_reason": null,
"delta": "new",
"inserted_at": "2024-01-20T10:30:00Z",
"updated_at": "2024-01-20T10:30:00Z",
"first_seen_at": "2024-01-20T10:30:00Z",
"check_metadata": {},
"raw_result": {}
},
"relationships": {
"scan": {
"data": {
"type": "scans",
"id": "scan-uuid"
}
},
"resources": {
"data": []
}
}
}
],
"meta": {
"page": {
"number": 1,
"size": 20,
"total": 45,
"total_pages": 3
}
},
"links": {
"self": "https://api.prowler.com/api/v1/findings/latest?page[number]=1",
"first": "https://api.prowler.com/api/v1/findings/latest?page[number]=1",
"next": "https://api.prowler.com/api/v1/findings/latest?page[number]=2",
"last": "https://api.prowler.com/api/v1/findings/latest?page[number]=3"
}
}
```
<Tip>
Use this endpoint for real-time monitoring and dashboards, as it automatically focuses on the latest scan results without requiring date filters.
</Tip>
+287
View File
@@ -0,0 +1,287 @@
---
title: "List Findings"
api: "GET /api/v1/findings"
description: "Retrieve a list of all findings with options for filtering by various criteria."
---
Retrieve security findings (vulnerabilities, misconfigurations, compliance violations) across your cloud infrastructure. This is one of the most frequently used endpoints for analyzing your security posture.
## Use Cases
- **Security Dashboard**: Display current security findings and trends
- **Compliance Reporting**: Filter findings by compliance framework
- **Vulnerability Management**: Track and remediate security issues
- **Alerting & Notifications**: Query new or critical findings for alerts
- **Risk Assessment**: Analyze findings by severity and service
- **Audit Trails**: Export findings for compliance audits
## Query Parameters
### Required Filters
- `filter[inserted_at]` - At least one variation of this filter is required
- `filter[inserted_at__gte]` - Filter by creation date (greater than or equal). Maximum date range is 7 days
- `filter[inserted_at__lte]` - Filter by creation date (less than or equal). Maximum date range is 7 days
### Filtering Options
**By Check:**
- `filter[check_id]`, `filter[check_id__icontains]`, `filter[check_id__in]` - Filter by check ID
**By Status:**
- `filter[status]` - Filter by status: `FAIL`, `PASS`, `MANUAL`
- `filter[severity]` - Filter by severity: `critical`, `high`, `medium`, `low`, `informational`
- `filter[delta]` - Filter by change status: `new`, `changed`
- `filter[muted]` - Filter by mute status (boolean)
**By Provider:**
- `filter[provider]`, `filter[provider__in]` - Filter by provider UUID
- `filter[provider_type]`, `filter[provider_type__in]` - Filter by provider type: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `oci`
- `filter[provider_alias]`, `filter[provider_alias__icontains]` - Filter by provider alias
- `filter[provider_uid]`, `filter[provider_uid__icontains]` - Filter by provider UID
**By Resource:**
- `filter[resource_name]`, `filter[resource_name__icontains]` - Filter by resource name
- `filter[resource_type]`, `filter[resource_type__icontains]` - Filter by resource type
- `filter[resource_uid]`, `filter[resource_uid__icontains]` - Filter by resource UID
- `filter[region]`, `filter[region__icontains]`, `filter[region__in]` - Filter by region
- `filter[service]`, `filter[service__icontains]` - Filter by cloud service
**By Scan:**
- `filter[scan]`, `filter[scan__in]` - Filter by scan UUID
### Pagination
- `page[number]` - Page number to retrieve
- `page[size]` - Number of results per page
### Sorting
- `sort` - Field to sort by: `status`, `-status`, `severity`, `-severity`, `check_id`, `-check_id`, `inserted_at`, `-inserted_at`, `updated_at`, `-updated_at`
### Field Selection
- `fields[findings]` - Specify which fields to return
### Include Related Resources
- `include` - Include related resources: `scan`, `resources`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings?filter[inserted_at__gte]=2024-01-01&filter[status]=FAIL&filter[severity]=high&page[size]=20" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns a paginated list of findings with full details:
```json
{
"data": [
{
"type": "findings",
"id": "finding-uuid",
"attributes": {
"uid": "prowler-aws-s3-bucket-unencrypted-123456",
"check_id": "s3_bucket_default_encryption",
"status": "FAIL",
"status_extended": "S3 Bucket 'my-bucket' does not have default encryption enabled",
"severity": "high",
"muted": false,
"muted_reason": null,
"delta": "new",
"inserted_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z",
"first_seen_at": "2024-01-15T10:30:00Z",
"check_metadata": {
"Provider": "aws",
"CheckID": "s3_bucket_default_encryption",
"CheckTitle": "Check if S3 buckets have default encryption enabled",
"CheckType": "Software and Configuration Checks",
"ServiceName": "s3",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:s3:::bucket_name",
"Severity": "high",
"ResourceType": "AwsS3Bucket",
"Description": "S3 buckets should have encryption at rest enabled...",
"Risk": "Unencrypted S3 buckets can expose sensitive data...",
"RelatedUrl": "https://docs.aws.amazon.com/...",
"Remediation": {
"Code": {
"CLI": "aws s3api put-bucket-encryption...",
"NativeIaC": "...",
"Other": "...",
"Terraform": "..."
},
"Recommendation": {
"Text": "Enable default encryption on S3 bucket",
"Url": "https://docs.aws.amazon.com/..."
}
},
"Categories": ["encryption"],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
},
"raw_result": {
"finding_unique_id": "...",
"finding_uid": "...",
"status": "FAIL",
"status_extended": "...",
"raw": {...}
}
},
"relationships": {
"scan": {
"data": {
"type": "scans",
"id": "scan-uuid"
}
},
"resources": {
"data": [
{
"type": "resources",
"id": "resource-uuid"
}
]
}
}
}
],
"meta": {
"page": {
"number": 1,
"size": 20,
"total": 156,
"total_pages": 8
}
},
"links": {
"self": "https://api.prowler.com/api/v1/findings?page[number]=1&page[size]=20",
"first": "https://api.prowler.com/api/v1/findings?page[number]=1&page[size]=20",
"next": "https://api.prowler.com/api/v1/findings?page[number]=2&page[size]=20",
"last": "https://api.prowler.com/api/v1/findings?page[number]=8&page[size]=20"
}
}
```
### Response Fields
**Core Finding Information:**
- `uid` (string, required) - Unique identifier for the finding (max 300 characters)
- `check_id` (string, required) - ID of the security check that generated this finding (max 100 characters)
- `status` (enum, required) - Finding status: `FAIL`, `PASS`, or `MANUAL`
- `status_extended` (string, nullable) - Detailed human-readable status message
- `severity` (enum, required) - Severity level: `critical`, `high`, `medium`, `low`, `informational`
- `delta` (enum, nullable) - Change status: `new`, `changed`, or `null`
**Mute Management:**
- `muted` (boolean) - Whether finding is muted (suppressed)
- `muted_reason` (string, nullable) - Reason for muting (3-500 characters)
**Timestamps:**
- `inserted_at` (datetime, read-only) - When finding was first created
- `updated_at` (datetime, read-only) - Last time finding was modified
- `first_seen_at` (datetime, nullable, read-only) - First time this finding was detected
**Check Details:**
- `check_metadata` (object) - Complete metadata about the security check including:
- Title, description, risk assessment
- Remediation steps and code examples
- Related documentation URLs
- Compliance frameworks and categories
- `raw_result` (object) - Complete raw output from the security check
**Relationships:**
- `scan` - Reference to the scan that generated this finding
- `resources` - References to affected cloud resources (read-only)
<Note>
Resource information (service, region, resource name, etc.) is available through the `resources` relationship. Use `include=resources` to fetch resource details along with findings.
</Note>
### Common Query Patterns
**Get all critical findings:**
```bash
curl "https://api.prowler.com/api/v1/findings?filter[severity]=critical&filter[status]=FAIL&filter[inserted_at__gte]=2024-01-01"
```
**Get new findings from last scan:**
```bash
curl "https://api.prowler.com/api/v1/findings/latest?filter[delta]=new&filter[status]=FAIL"
```
**Get findings for specific service:**
```bash
curl "https://api.prowler.com/api/v1/findings?filter[service]=s3&filter[inserted_at__gte]=2024-01-01"
```
**Get findings for specific region:**
```bash
curl "https://api.prowler.com/api/v1/findings?filter[region]=us-east-1&filter[inserted_at__gte]=2024-01-01"
```
**Get unmuted high/critical findings:**
```bash
curl "https://api.prowler.com/api/v1/findings?filter[severity__in]=critical,high&filter[muted]=false&filter[inserted_at__gte]=2024-01-01"
```
### Pagination
Use pagination parameters to navigate large result sets:
```bash
# First page, 50 results
curl "https://api.prowler.com/api/v1/findings?page[size]=50&page[number]=1&filter[inserted_at__gte]=2024-01-01"
# Next page
curl "https://api.prowler.com/api/v1/findings?page[size]=50&page[number]=2&filter[inserted_at__gte]=2024-01-01"
```
### Error Responses
**400 Bad Request** - Invalid filter or parameter
```json
{
"errors": [
{
"status": "400",
"title": "Invalid Filter",
"detail": "Date range cannot exceed 7 days for inserted_at filters"
}
]
}
```
**422 Unprocessable Entity** - Missing required filter
```json
{
"errors": [
{
"status": "422",
"title": "Validation Error",
"detail": "At least one variation of filter[inserted_at] is required"
}
]
}
```
<Note>
**Performance Tip:** Maximum date range for `inserted_at` filters is 7 days. For historical analysis, use multiple requests or export data to external storage.
</Note>
<Warning>
**Rate Limiting:** This endpoint is rate-limited to 100 requests per minute. For real-time monitoring, consider using webhooks or the `/findings/latest` endpoint.
</Warning>
<Tip>
Use `filter[delta]=new` to only retrieve findings discovered in the most recent scan, perfect for alerting on new security issues.
</Tip>
@@ -0,0 +1,79 @@
---
title: "Retrieve Latest Findings Metadata"
api: "GET /api/v1/findings/metadata/latest"
description: "Fetch unique metadata values from the latest scans for each provider. This is useful for dynamic filtering."
---
Retrieve metadata values such as services, regions, and resource types from the latest findings for each provider. This endpoint automatically filters to only include data from the most recent scans.
## Query Parameters
### Filtering Options
**By Check:**
- `filter[check_id]`, `filter[check_id__icontains]`, `filter[check_id__in]` - Filter by check ID
**By Status:**
- `filter[status]` - Filter by status: `FAIL`, `PASS`, `MANUAL`
- `filter[severity]` - Filter by severity: `critical`, `high`, `medium`, `low`, `informational`
- `filter[delta]` - Filter by change status: `new`, `changed`
- `filter[muted]` - Filter by mute status (boolean)
**By Provider:**
- `filter[provider]`, `filter[provider__in]` - Filter by provider UUID
- `filter[provider_type]`, `filter[provider_type__in]` - Filter by provider type: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `oci`
- `filter[provider_alias]`, `filter[provider_alias__icontains]` - Filter by provider alias
- `filter[provider_uid]`, `filter[provider_uid__icontains]` - Filter by provider UID
**By Resource:**
- `filter[resource_name]`, `filter[resource_name__icontains]` - Filter by resource name
- `filter[resource_type]`, `filter[resource_type__icontains]` - Filter by resource type
- `filter[resource_uid]`, `filter[resource_uid__icontains]` - Filter by resource UID
- `filter[region]`, `filter[region__icontains]`, `filter[region__in]` - Filter by region
- `filter[service]`, `filter[service__icontains]` - Filter by cloud service
### Sorting
- `sort` - Field to sort by: `status`, `-status`, `severity`, `-severity`, `check_id`, `-check_id`, `inserted_at`, `-inserted_at`, `updated_at`, `-updated_at`
### Field Selection
- `fields[findings-metadata]` - Specify which fields to return: `services`, `regions`, `resource_types`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/metadata/latest?filter[provider_type]=aws" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns unique metadata values from the latest scan findings:
```json
{
"data": {
"type": "findings-metadata",
"id": "metadata",
"attributes": {
"services": ["s3", "ec2", "iam", "vpc", "cloudtrail"],
"regions": ["us-east-1", "us-west-2", "eu-west-1"],
"resource_types": ["aws_s3_bucket", "aws_ec2_instance", "aws_iam_role"]
}
}
}
```
### Response Fields
- `services` (array of strings) - List of unique cloud services from latest scans
- `regions` (array of strings) - List of unique regions from latest scans
- `resource_types` (array of strings) - List of unique resource types from latest scans
<Note>
This endpoint is optimized for dashboard views where you only want to show data from the most recent scans.
</Note>
+88
View File
@@ -0,0 +1,88 @@
---
title: "Retrieve Findings Metadata"
api: "GET /api/v1/findings/metadata"
description: "Fetch unique metadata values from a set of findings. This is useful for dynamic filtering."
---
Retrieve metadata values such as services, regions, and resource types from your findings. This endpoint is useful for building dynamic filters in your application.
## Query Parameters
### Required Filters
- `filter[inserted_at]` - At least one variation of this filter is required
- `filter[inserted_at__gte]` - Filter by creation date (greater than or equal). Maximum date range is 7 days
- `filter[inserted_at__lte]` - Filter by creation date (less than or equal). Maximum date range is 7 days
### Filtering Options
**By Check:**
- `filter[check_id]`, `filter[check_id__icontains]`, `filter[check_id__in]` - Filter by check ID
**By Status:**
- `filter[status]` - Filter by status: `FAIL`, `PASS`, `MANUAL`
- `filter[severity]` - Filter by severity: `critical`, `high`, `medium`, `low`, `informational`
- `filter[delta]` - Filter by change status: `new`, `changed`
- `filter[muted]` - Filter by mute status (boolean)
**By Provider:**
- `filter[provider]`, `filter[provider__in]` - Filter by provider UUID
- `filter[provider_type]`, `filter[provider_type__in]` - Filter by provider type: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `oci`
- `filter[provider_alias]`, `filter[provider_alias__icontains]` - Filter by provider alias
- `filter[provider_uid]`, `filter[provider_uid__icontains]` - Filter by provider UID
**By Resource:**
- `filter[resource_name]`, `filter[resource_name__icontains]` - Filter by resource name
- `filter[resource_type]`, `filter[resource_type__icontains]` - Filter by resource type
- `filter[resource_uid]`, `filter[resource_uid__icontains]` - Filter by resource UID
- `filter[region]`, `filter[region__icontains]`, `filter[region__in]` - Filter by region
- `filter[service]`, `filter[service__icontains]` - Filter by cloud service
**By Scan:**
- `filter[scan]`, `filter[scan__in]` - Filter by scan UUID
### Sorting
- `sort` - Field to sort by: `status`, `-status`, `severity`, `-severity`, `check_id`, `-check_id`, `inserted_at`, `-inserted_at`, `updated_at`, `-updated_at`
### Field Selection
- `fields[findings-metadata]` - Specify which fields to return: `services`, `regions`, `resource_types`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/metadata?filter[inserted_at__gte]=2024-01-01" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns unique metadata values from the filtered findings:
```json
{
"data": {
"type": "findings-metadata",
"id": "metadata",
"attributes": {
"services": ["s3", "ec2", "iam", "rds", "lambda", "cloudtrail"],
"regions": ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"],
"resource_types": ["aws_s3_bucket", "aws_ec2_instance", "aws_iam_user", "aws_rds_db_instance"]
}
}
}
```
### Response Fields
- `services` (array of strings) - List of unique cloud services found in the findings
- `regions` (array of strings) - List of unique regions found in the findings
- `resource_types` (array of strings) - List of unique resource types found in the findings
<Note>
This endpoint is useful for populating dropdowns and filters in your UI with actual values from your findings data. The maximum date range is 7 days.
</Note>
+133
View File
@@ -0,0 +1,133 @@
---
title: "Retrieve Finding"
api: "GET /api/v1/findings/{id}"
description: "Fetch detailed information about a specific finding by its ID."
---
Retrieve complete details about a specific security finding, including check metadata, raw results, and affected resources.
## Path Parameters
- `id` (required) - The UUID of the finding to retrieve
## Query Parameters
### Field Selection
- `fields[findings]` - Specify which fields to return: `uid`, `delta`, `status`, `status_extended`, `severity`, `check_id`, `check_metadata`, `raw_result`, `inserted_at`, `updated_at`, `first_seen_at`, `muted`, `muted_reason`, `url`, `scan`, `resources`
### Include Related Resources
- `include` - Include related resources: `scan`, `resources`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/{id}?include=scan,resources" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns detailed information about the finding:
```json
{
"data": {
"type": "findings",
"id": "finding-uuid",
"attributes": {
"uid": "prowler-aws-s3-bucket-unencrypted-123456",
"check_id": "s3_bucket_default_encryption",
"status": "FAIL",
"status_extended": "S3 Bucket 'my-bucket' does not have default encryption enabled",
"severity": "high",
"muted": false,
"muted_reason": null,
"delta": "new",
"inserted_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z",
"first_seen_at": "2024-01-15T10:30:00Z",
"check_metadata": {
"Provider": "aws",
"CheckID": "s3_bucket_default_encryption",
"CheckTitle": "Check if S3 buckets have default encryption enabled",
"CheckType": "Software and Configuration Checks",
"ServiceName": "s3",
"Severity": "high",
"ResourceType": "AwsS3Bucket",
"Description": "S3 buckets should have encryption at rest enabled...",
"Risk": "Unencrypted S3 buckets can expose sensitive data...",
"RelatedUrl": "https://docs.aws.amazon.com/...",
"Remediation": {
"Code": {
"CLI": "aws s3api put-bucket-encryption...",
"Terraform": "..."
},
"Recommendation": {
"Text": "Enable default encryption on S3 bucket",
"Url": "https://docs.aws.amazon.com/..."
}
},
"Categories": ["encryption"]
},
"raw_result": {
"finding_unique_id": "...",
"status": "FAIL",
"status_extended": "...",
"raw": {}
}
},
"relationships": {
"scan": {
"data": {
"type": "scans",
"id": "scan-uuid"
}
},
"resources": {
"data": [
{
"type": "resources",
"id": "resource-uuid"
}
]
}
}
}
}
```
### Response Fields
- `uid` (string, required) - Unique identifier for the finding (max 300 characters)
- `check_id` (string, required) - ID of the security check (max 100 characters)
- `status` (enum, required) - Finding status: `FAIL`, `PASS`, or `MANUAL`
- `status_extended` (string, nullable) - Detailed status message
- `severity` (enum, required) - Severity: `critical`, `high`, `medium`, `low`, `informational`
- `delta` (enum, nullable) - Change status: `new`, `changed`, or `null`
- `muted` (boolean) - Whether finding is muted
- `muted_reason` (string, nullable) - Reason for muting (3-500 characters)
- `inserted_at` (datetime, read-only) - Creation timestamp
- `updated_at` (datetime, read-only) - Last modification timestamp
- `first_seen_at` (datetime, nullable, read-only) - First detection timestamp
- `check_metadata` (object) - Complete check metadata
- `raw_result` (object) - Raw scan output
### Error Responses
**404 Not Found** - Finding does not exist
```json
{
"errors": [
{
"status": "404",
"title": "Not Found",
"detail": "Finding not found"
}
]
}
```
@@ -0,0 +1,52 @@
---
title: "Get Services and Regions"
api: "GET /api/v1/findings/findings_services_regions"
description: "Fetch services and regions affected in findings."
---
<Warning>
This endpoint is deprecated. Please use alternative filtering methods for retrieving services and regions information.
</Warning>
Retrieve a list of unique services and regions that have findings matching the specified criteria. This is useful for populating dynamic filters in user interfaces.
## Query Parameters
### Field Selection
- `fields[finding-dynamic-filters]` - Specify which fields to return: `services`, `regions`
### Filtering
The endpoint supports the same filtering options as the regular findings list endpoint to scope the services and regions returned.
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/findings_services_regions?filter[provider_type]=aws&filter[status]=FAIL" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns unique services and regions from findings:
```json
{
"data": {
"type": "finding-dynamic-filters",
"id": "filters",
"attributes": {
"services": ["s3", "ec2", "iam"],
"regions": ["us-east-1", "us-west-2"]
}
}
}
```
<Warning>
This endpoint is deprecated. Use `/api/v1/findings/metadata` or `/api/v1/findings/metadata/latest` instead.
</Warning>
@@ -0,0 +1,45 @@
---
title: "Check Integration Connection"
api: "POST /api/v1/integrations/{id}/connection"
description: "Try to verify integration connection."
---
Test the connection to an integration to ensure it is configured correctly and can communicate with the external service.
## Path Parameters
- `id` (required) - UUID of the integration to test
## Example Request
```bash
curl -X POST "https://api.prowler.com/api/v1/integrations/{id}/connection" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns a task object. The connection test is performed asynchronously.
```json
{
"data": {
"type": "tasks",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"attributes": {
"inserted_at": "2019-08-24T14:15:22Z",
"completed_at": "2019-08-24T14:15:22Z",
"name": "string",
"state": "available",
"result": null,
"task_args": null,
"metadata": null
}
}
}
```
<Note>
Use the returned task ID to check the connection test status via the Tasks API.
</Note>
@@ -0,0 +1,7 @@
---
title: "Create Integration"
api: "POST /api/v1/integrations"
description: "Configure a new integration."
---
Add integration with external systems.
@@ -0,0 +1,7 @@
---
title: "Delete Integration"
api: "DELETE /api/v1/integrations/{id}"
description: "Remove an integration."
---
Delete integration configuration.
@@ -0,0 +1,7 @@
---
title: "Create Jira Dispatch"
api: "POST /api/v1/integrations/{id}/jira/dispatches"
description: "Send findings to Jira."
---
Dispatch findings to Jira for tracking.
+7
View File
@@ -0,0 +1,7 @@
---
title: "List Integrations"
api: "GET /api/v1/integrations"
description: "Retrieve configured integrations."
---
Get all configured integrations (S3, Security Hub, Jira, etc).
@@ -0,0 +1,7 @@
---
title: "Retrieve Integration"
api: "GET /api/v1/integrations/{id}"
description: "Get integration details."
---
Fetch specific integration configuration.
@@ -0,0 +1,7 @@
---
title: "Update Integration"
api: "PATCH /api/v1/integrations/{id}"
description: "Update integration settings."
---
Modify integration configuration.
+121
View File
@@ -0,0 +1,121 @@
---
title: "API Reference"
description: "Comprehensive API documentation for Prowler Cloud"
---
## Welcome to the Prowler API
The Prowler API provides programmatic access to Prowler Cloud's security compliance scanning and reporting capabilities. This API follows RESTful principles and uses JSON:API specification for request and response formatting.
### Base URL
```
https://api.prowler.com/api/v1
```
### Authentication
All API requests require authentication using either:
- **JWT Token**: Obtained through user authentication
- **API Key**: Generated from your Prowler Cloud account
Include your authentication credentials in the `Authorization` header:
```bash
Authorization: Bearer YOUR_API_KEY_OR_JWT_TOKEN
```
### API Keys Management
You can manage your API keys through the [API Keys endpoints](/api-reference/api-keys/list). API keys provide:
- Programmatic access to your Prowler Cloud resources
- Fine-grained access control
- Expiration and revocation capabilities
- Usage tracking
### Request Format
All requests should include the following headers:
```
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json
Authorization: Bearer YOUR_API_KEY_OR_JWT_TOKEN
```
### Response Format
The API follows the [JSON:API specification](https://jsonapi.org/). Responses include:
- **data**: The primary data for the response
- **included**: Related resources (when using `include` parameter)
- **meta**: Metadata about the response
- **links**: Pagination links
### Pagination
List endpoints support pagination using the following query parameters:
- `page[number]`: The page number to retrieve
- `page[size]`: Number of results per page
### Filtering
Most list endpoints support filtering using query parameters in the format:
```
filter[field_name]=value
filter[field_name__operator]=value
```
Common operators:
- `__gte`: Greater than or equal
- `__lte`: Less than or equal
- `__icontains`: Case-insensitive contains
- `__in`: In a list of values
### Sorting
Use the `sort` parameter to order results:
```
sort=field_name # Ascending order
sort=-field_name # Descending order
```
### Field Selection
Optimize responses by requesting only specific fields:
```
fields[resource-type]=field1,field2,field3
```
### Rate Limits
API requests are subject to rate limiting to ensure service stability. Rate limit information is included in response headers.
### Support
For questions or issues with the API:
- Join our [Slack community](https://goto.prowler.com/slack)
- Visit [Prowler Cloud](https://cloud.prowler.com)
- Check our [GitHub repository](https://github.com/prowler-cloud/prowler)
## API Endpoints
Explore the complete API reference using the navigation menu. Main endpoint categories include:
- **API Keys**: Manage programmatic access credentials
- **Findings**: Security findings and vulnerabilities
- **Compliance**: Compliance frameworks and requirements
- **Scans**: Security scan operations and results
- **Providers**: Cloud provider configurations
- **Resources**: Cloud resources inventory
<Note>
The Prowler API is currently at version 1.15.0. We maintain backward compatibility and announce breaking changes in advance.
</Note>
+50
View File
@@ -0,0 +1,50 @@
---
title: "Accept Invitation"
api: "POST /api/v1/invitations/accept"
description: "Accept an invitation to an existing tenant."
---
Accept an invitation to join a tenant. This invitation cannot be expired and the emails must match.
## Request Body
The request must follow the JSON:API specification format:
```json
{
"data": {
"type": "invitations",
"attributes": {
"token": "invitation-token"
}
}
}
```
### Attributes
- `token` (required) - The invitation token received via email
## Example Request
```bash
curl -X POST "https://api.prowler.com/api/v1/invitations/accept" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "invitations",
"attributes": {
"token": "your-invitation-token"
}
}
}'
```
## Response
Returns a success response indicating you have been added to the tenant.
<Note>
The email address associated with your account must match the email address to which the invitation was sent.
</Note>
@@ -0,0 +1,7 @@
---
title: "List Lighthouse Config (Legacy)"
api: "GET /api/v1/lighthouse/configuration"
description: "Legacy configuration endpoint."
---
Legacy endpoint for configuration.
@@ -0,0 +1,7 @@
---
title: "Update Lighthouse Config (Legacy)"
api: "PATCH /api/v1/lighthouse/configuration"
description: "Update legacy configuration."
---
Legacy endpoint for updating configuration.
@@ -0,0 +1,19 @@
---
title: "Get Lighthouse AI Tenant Config"
api: "GET /api/v1/lighthouse/configuration"
description: "Retrieve current tenant-level Lighthouse AI settings."
---
Get your tenant's Lighthouse AI configuration including business context and default models.
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/lighthouse/configuration" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns tenant Lighthouse AI configuration.
@@ -0,0 +1,37 @@
---
title: "Update Lighthouse AI Tenant Config"
api: "PATCH /api/v1/lighthouse/configuration"
description: "Update tenant-level Lighthouse AI settings."
---
Update your tenant's Lighthouse AI configuration including business context and default providers/models.
## Request Body
```json
{
"data": {
"type": "lighthouse-config",
"attributes": {
"business_context": "Financial services company focusing on security and compliance",
"default_provider": "openai",
"default_models": {
"chat": "gpt-4",
"analysis": "gpt-4"
}
}
}
}
```
## Example Request
```bash
curl -X PATCH "https://api.prowler.com/api/v1/lighthouse/configuration" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns updated configuration.
@@ -0,0 +1,7 @@
---
title: "Create Lighthouse Configuration"
api: "POST /api/v1/lighthouse/configurations"
description: "Create AI configuration profile."
---
Configure Lighthouse AI settings.
@@ -0,0 +1,7 @@
---
title: "Delete Lighthouse Configuration"
api: "DELETE /api/v1/lighthouse/configurations/{id}"
description: "Remove configuration profile."
---
Delete Lighthouse configuration.
@@ -0,0 +1,7 @@
---
title: "List Lighthouse Configurations"
api: "GET /api/v1/lighthouse/configurations"
description: "Retrieve Lighthouse AI configurations."
---
Get Lighthouse configuration profiles.
@@ -0,0 +1,7 @@
---
title: "Retrieve Lighthouse Configuration"
api: "GET /api/v1/lighthouse/configurations/{id}"
description: "Get configuration details."
---
Fetch Lighthouse configuration profile.
@@ -0,0 +1,7 @@
---
title: "Test Lighthouse Configuration"
api: "POST /api/v1/lighthouse/configurations/{id}/connection"
description: "Test configuration."
---
Verify Lighthouse configuration works.
@@ -0,0 +1,7 @@
---
title: "Update Lighthouse Configuration"
api: "PATCH /api/v1/lighthouse/configurations/{id}"
description: "Update configuration profile."
---
Modify Lighthouse AI settings.
@@ -0,0 +1,25 @@
---
title: "List LLM Models"
api: "GET /api/v1/lighthouse/models"
description: "List available LLM models per configured provider."
---
Retrieve all available LLM models from your configured providers.
## Query Parameters
- `filter[provider_type]` - Filter by provider type
- `filter[model_id]`, `filter[model_id__icontains]` - Filter by model ID
- `page[number]`, `page[size]` - Pagination
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/lighthouse/models?filter[provider_type]=openai" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns list of available LLM models with their parameters.
@@ -0,0 +1,7 @@
---
title: "List Lighthouse Models"
api: "GET /api/v1/lighthouse/models"
description: "Retrieve available AI models."
---
Get list of available LLM models.
@@ -0,0 +1,7 @@
---
title: "Retrieve Lighthouse Model"
api: "GET /api/v1/lighthouse/models/{id}"
description: "Get AI model details."
---
Fetch information about a specific model.
@@ -0,0 +1,40 @@
---
title: "Create LLM Provider Config"
api: "POST /api/v1/lighthouse/providers"
description: "Create configuration for an LLM provider."
---
Configure an LLM provider for use with Lighthouse AI.
## Request Body
```json
{
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"base_url": "https://api.openai.com/v1",
"credentials": {
"api_key": "sk-..."
}
}
}
}
```
## Example Request
```bash
curl -X POST "https://api.prowler.com/api/v1/lighthouse/providers" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns the created provider configuration.
<Note>
Only one configuration per provider type is allowed per tenant.
</Note>
@@ -0,0 +1,25 @@
---
title: "List LLM Provider Configs"
api: "GET /api/v1/lighthouse/providers"
description: "Retrieve all LLM provider configurations for Lighthouse AI."
---
Retrieve all configured LLM provider configurations (OpenAI, etc.) for Lighthouse AI.
## Query Parameters
- `filter[provider_type]` - Filter by provider type: `openai`
- `filter[is_active]` - Filter by active status (boolean)
- `page[number]`, `page[size]` - Pagination
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/lighthouse/providers" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns list of LLM provider configurations.
@@ -0,0 +1,21 @@
---
title: "Create Lighthouse Provider"
api: "POST /api/v1/lighthouse/providers"
description: "Add AI provider for Lighthouse."
---
Configure an LLM provider.
## Request Body
\`\`\`json
{
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"api_key": "sk-...",
"name": "OpenAI Production"
}
}
}
\`\`\`
@@ -0,0 +1,7 @@
---
title: "Delete Lighthouse Provider"
api: "DELETE /api/v1/lighthouse/providers/{id}"
description: "Remove AI provider."
---
Delete LLM provider configuration.
@@ -0,0 +1,7 @@
---
title: "List Lighthouse Providers"
api: "GET /api/v1/lighthouse/providers"
description: "Retrieve LLM providers for Lighthouse AI."
---
Get configured AI/LLM providers (OpenAI, Azure OpenAI, etc).
@@ -0,0 +1,7 @@
---
title: "Refresh Lighthouse Provider Models"
api: "POST /api/v1/lighthouse/providers/{id}/refresh-models"
description: "Refresh available models from provider."
---
Update the list of available AI models.
@@ -0,0 +1,7 @@
---
title: "Retrieve Lighthouse Provider"
api: "GET /api/v1/lighthouse/providers/{id}"
description: "Get AI provider details."
---
Fetch LLM provider configuration.
@@ -0,0 +1,7 @@
---
title: "Test Lighthouse Provider Connection"
api: "POST /api/v1/lighthouse/providers/{id}/connection"
description: "Test AI provider connectivity."
---
Verify LLM provider is properly configured.
@@ -0,0 +1,7 @@
---
title: "Update Lighthouse Provider"
api: "PATCH /api/v1/lighthouse/providers/{id}"
description: "Update AI provider settings."
---
Modify LLM provider configuration.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,7 @@
---
title: "Findings Severity Overview"
api: "GET /api/v1/overviews/findings-severity"
description: "Get overview statistics for findings-severity."
---
Retrieve aggregated statistics and metrics.
@@ -0,0 +1,7 @@
---
title: "Findings Overview"
api: "GET /api/v1/overviews/findings"
description: "Get overview statistics for findings."
---
Retrieve aggregated statistics and metrics.
@@ -0,0 +1,7 @@
---
title: "Providers Count Overview"
api: "GET /api/v1/overviews/providers-count"
description: "Get overview statistics for providers-count."
---
Retrieve aggregated statistics and metrics.
@@ -0,0 +1,7 @@
---
title: "Providers Overview"
api: "GET /api/v1/overviews/providers"
description: "Get overview statistics for providers."
---
Retrieve aggregated statistics and metrics.
@@ -0,0 +1,7 @@
---
title: "Services Overview"
api: "GET /api/v1/overviews/services"
description: "Get overview statistics for services."
---
Retrieve aggregated statistics and metrics.
+7
View File
@@ -0,0 +1,7 @@
---
title: "Create Processor"
api: "POST /api/v1/processors"
description: "create processor configuration."
---
Processor operations for result processing.
+7
View File
@@ -0,0 +1,7 @@
---
title: "Delete Processor"
api: "DELETE /api/v1/processors/{id}"
description: "delete processor configuration."
---
Processor operations for result processing.
+7
View File
@@ -0,0 +1,7 @@
---
title: "List Processor"
api: "GET /api/v1/processors"
description: "list processor configuration."
---
Processor operations for result processing.
@@ -0,0 +1,7 @@
---
title: "Retrieve Processor"
api: "PATCH /api/v1/processors/{id}"
description: "retrieve processor configuration."
---
Processor operations for result processing.
+7
View File
@@ -0,0 +1,7 @@
---
title: "Update Processor"
api: "PATCH /api/v1/processors/{id}"
description: "update processor configuration."
---
Processor operations for result processing.
@@ -0,0 +1,7 @@
---
title: "Add Providers to Group"
api: "POST /api/v1/provider-groups/{id}/relationships/providers"
description: "Add providers to a group."
---
Associate providers with a provider group.
@@ -0,0 +1,20 @@
---
title: "Create Provider Group"
api: "POST /api/v1/provider-groups"
description: "Create a new provider group."
---
Create a group to organize multiple providers together.
## Request Body
\`\`\`json
{
"data": {
"type": "provider-groups",
"attributes": {
"name": "Production Providers",
"description": "All production cloud accounts"
}
}
}
\`\`\`
@@ -0,0 +1,7 @@
---
title: "Delete Provider Group"
api: "DELETE /api/v1/provider-groups/{id}"
description: "Remove a provider group."
---
Delete a provider group. Providers in the group are not deleted.
@@ -0,0 +1,17 @@
---
title: "List Provider Groups"
api: "GET /api/v1/provider-groups"
description: "Retrieve provider groups for organizing providers."
---
Get all provider groups used to organize and manage multiple providers.
## Query Parameters
- `filter[name]` - Filter by group name
- `page[number]`, `page[size]` - Pagination
## Example
\`\`\`bash
curl "https://api.prowler.com/api/v1/provider-groups" \\
-H "Authorization: Bearer YOUR_API_KEY"
\`\`\`
@@ -0,0 +1,7 @@
---
title: "Remove Providers from Group"
api: "DELETE /api/v1/provider-groups/{id}/relationships/providers"
description: "Remove providers from group."
---
Disassociate providers from a provider group.
@@ -0,0 +1,7 @@
---
title: "Retrieve Provider Group"
api: "GET /api/v1/provider-groups/{id}"
description: "Get provider group details."
---
Fetch information about a specific provider group.
@@ -0,0 +1,7 @@
---
title: "Update Group Providers"
api: "PATCH /api/v1/provider-groups/{id}/relationships/providers"
description: "Update providers in group."
---
Modify the list of providers in a group.
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more