mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 17:34:35 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd4636b5d3 | ||
|
|
39710a6841 | ||
|
|
f330440c54 | ||
|
|
c3940c7454 | ||
|
|
df39f332e4 | ||
|
|
4a364d91be | ||
|
|
4b99c7b651 | ||
|
|
c441423d6a | ||
|
|
7e7f160b9a | ||
|
|
aaae73cd1c | ||
|
|
c5e88f4a74 | ||
|
|
5d4415d090 | ||
|
|
5d840385df |
No files matched your search
@@ -35,6 +35,8 @@ 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,19 +43,27 @@ 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": "$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>"
|
||||
}
|
||||
"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>"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"channel": "$SLACK_CHANNEL_ID",
|
||||
"text": "$COMPONENT container release $RELEASE_TAG push started... <$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID|View run>"
|
||||
"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>"
|
||||
}
|
||||
@@ -25,7 +25,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
api-analyze:
|
||||
name: CodeQL Security Analysis
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -28,6 +28,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
sdk-code-quality:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
|
||||
@@ -31,7 +31,8 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
sdk-analyze:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
name: CodeQL Security Analysis
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -29,6 +29,7 @@ env:
|
||||
|
||||
jobs:
|
||||
sdk-dockerfile-lint:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
@@ -52,6 +53,7 @@ jobs:
|
||||
ignore: DL3013
|
||||
|
||||
sdk-container-build-and-scan:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
@@ -100,7 +102,7 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan SDK container with Trivy
|
||||
if: github.repository == 'prowler-cloud/prowler' && steps.check-changes.outputs.any_changed == 'true'
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/trivy-scan
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -28,6 +28,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
sdk-security-scans:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
|
||||
@@ -28,6 +28,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
sdk-tests:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
|
||||
@@ -27,7 +27,8 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
ui-analyze:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
name: CodeQL Security Analysis
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -10,14 +10,21 @@ 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
|
||||
|
||||
Generated
+57
-2
@@ -1164,6 +1164,18 @@ 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"
|
||||
@@ -4046,6 +4058,29 @@ 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"
|
||||
@@ -4580,7 +4615,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.13.0"
|
||||
version = "5.14.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"
|
||||
@@ -4634,6 +4669,7 @@ 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"
|
||||
@@ -4651,7 +4687,7 @@ tzlocal = "5.3.1"
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "master"
|
||||
resolved_reference = "a52697bfdfee83d14a49c11dcbe96888b5cd767e"
|
||||
resolved_reference = "a41f8dcb18190e44cebed11d5cea8850bd3ba80e"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
@@ -5136,6 +5172,25 @@ 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"
|
||||
|
||||
@@ -9,25 +9,6 @@ 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]:
|
||||
"""
|
||||
@@ -47,9 +28,8 @@ 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(prowler_provider_name)
|
||||
get_available_compliance_frameworks(provider_type)
|
||||
)
|
||||
|
||||
return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type]
|
||||
@@ -69,8 +49,7 @@ def get_prowler_provider_checks(provider_type: Provider.ProviderChoices):
|
||||
Returns:
|
||||
Iterable[str]: An iterable of check IDs associated with the specified provider type.
|
||||
"""
|
||||
prowler_provider_name = get_prowler_provider_name(provider_type)
|
||||
return CheckMetadata.get_bulk(prowler_provider_name).keys()
|
||||
return CheckMetadata.get_bulk(provider_type).keys()
|
||||
|
||||
|
||||
def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) -> dict:
|
||||
@@ -88,8 +67,7 @@ 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.
|
||||
"""
|
||||
prowler_provider_name = get_prowler_provider_name(provider_type)
|
||||
return Compliance.get_bulk(prowler_provider_name)
|
||||
return Compliance.get_bulk(provider_type)
|
||||
|
||||
|
||||
def load_prowler_compliance():
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
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, connection, connections, models, transaction
|
||||
from django.db import (
|
||||
DEFAULT_DB_ALIAS,
|
||||
OperationalError,
|
||||
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 get_read_db_alias, reset_read_db_alias, set_read_db_alias
|
||||
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__)
|
||||
|
||||
DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test"
|
||||
DB_PASSWORD = (
|
||||
@@ -28,6 +45,9 @@ 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);"
|
||||
|
||||
|
||||
@@ -71,24 +91,51 @@ def rls_transaction(
|
||||
if db_alias not in connections:
|
||||
db_alias = DEFAULT_DB_ALIAS
|
||||
|
||||
router_token = None
|
||||
try:
|
||||
if db_alias != DEFAULT_DB_ALIAS:
|
||||
router_token = set_read_db_alias(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
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
class CustomUserManager(BaseUserManager):
|
||||
|
||||
@@ -30,6 +30,7 @@ from api.models import (
|
||||
LighthouseProviderConfiguration,
|
||||
LighthouseProviderModels,
|
||||
Membership,
|
||||
MuteRule,
|
||||
OverviewStatusChoices,
|
||||
PermissionChoices,
|
||||
Processor,
|
||||
@@ -980,3 +981,20 @@ 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"],
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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,
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -823,6 +823,9 @@ 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
|
||||
@@ -1935,6 +1938,59 @@ 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")
|
||||
@@ -1983,6 +2039,8 @@ 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
@@ -0,0 +1,39 @@
|
||||
"""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,12 +1,15 @@
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, 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,
|
||||
@@ -14,11 +17,22 @@ 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):
|
||||
@@ -339,3 +353,498 @@ 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
|
||||
@@ -34,6 +34,7 @@ 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,
|
||||
@@ -61,6 +62,8 @@ 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:
|
||||
@@ -9393,3 +9396,934 @@ 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,5 +1,6 @@
|
||||
import re
|
||||
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework_json_api import serializers
|
||||
|
||||
|
||||
@@ -11,3 +12,198 @@ 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
|
||||
@@ -31,6 +31,7 @@ from api.models import (
|
||||
LighthouseProviderModels,
|
||||
LighthouseTenantConfiguration,
|
||||
Membership,
|
||||
MuteRule,
|
||||
Processor,
|
||||
Provider,
|
||||
ProviderGroup,
|
||||
@@ -59,7 +60,13 @@ from api.v1.serializer_utils.integrations import (
|
||||
S3ConfigSerializer,
|
||||
SecurityHubConfigSerializer,
|
||||
)
|
||||
from api.v1.serializer_utils.lighthouse import OpenAICredentialsSerializer
|
||||
from api.v1.serializer_utils.lighthouse import (
|
||||
BedrockCredentialsSerializer,
|
||||
BedrockCredentialsUpdateSerializer,
|
||||
LighthouseCredentialsField,
|
||||
OpenAICompatibleCredentialsSerializer,
|
||||
OpenAICredentialsSerializer,
|
||||
)
|
||||
from api.v1.serializer_utils.processors import ProcessorConfigField
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from prowler.lib.mutelist.mutelist import Mutelist
|
||||
@@ -3075,7 +3082,12 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
|
||||
Accepts credentials as JSON; stored encrypted via credentials_decoded.
|
||||
"""
|
||||
|
||||
credentials = serializers.JSONField(write_only=True, required=True)
|
||||
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.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = LighthouseProviderConfiguration
|
||||
@@ -3087,7 +3099,10 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
|
||||
]
|
||||
extra_kwargs = {
|
||||
"is_active": {"required": False},
|
||||
"base_url": {"required": False, "allow_null": True},
|
||||
"provider_type": {
|
||||
"help_text": "LLM provider type. Determines which credential format to use. "
|
||||
"See 'credentials' field documentation for provider-specific requirements."
|
||||
},
|
||||
}
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -3110,6 +3125,7 @@ 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:
|
||||
@@ -3122,6 +3138,35 @@ 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)
|
||||
|
||||
@@ -3131,7 +3176,12 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
|
||||
Update serializer for LighthouseProviderConfiguration.
|
||||
"""
|
||||
|
||||
credentials = serializers.JSONField(write_only=True, required=False)
|
||||
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.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = LighthouseProviderConfiguration
|
||||
@@ -3145,7 +3195,6 @@ 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},
|
||||
}
|
||||
|
||||
@@ -3156,7 +3205,11 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
|
||||
setattr(instance, attr, value)
|
||||
|
||||
if credentials is not None:
|
||||
instance.credentials_decoded = credentials
|
||||
# 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.save()
|
||||
return instance
|
||||
@@ -3164,6 +3217,7 @@ 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
|
||||
@@ -3180,6 +3234,40 @@ 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)
|
||||
|
||||
@@ -3345,3 +3433,176 @@ 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
|
||||
@@ -21,6 +21,7 @@ from api.v1.views import (
|
||||
LighthouseProviderModelsViewSet,
|
||||
LighthouseTenantConfigViewSet,
|
||||
MembershipViewSet,
|
||||
MuteRuleViewSet,
|
||||
OverviewViewSet,
|
||||
ProcessorViewSet,
|
||||
ProviderGroupProvidersRelationshipView,
|
||||
@@ -80,6 +81,7 @@ 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(
|
||||
@@ -150,7 +152,6 @@ urlpatterns = [
|
||||
),
|
||||
name="provider_group-providers-relationship",
|
||||
),
|
||||
# Lighthouse tenant config as singleton endpoint
|
||||
path(
|
||||
"lighthouse/configuration",
|
||||
LighthouseTenantConfigViewSet.as_view(
|
||||
|
||||
+114
-28
@@ -66,6 +66,7 @@ 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,
|
||||
)
|
||||
@@ -90,6 +91,7 @@ from api.filters import (
|
||||
LighthouseProviderConfigFilter,
|
||||
LighthouseProviderModelsFilter,
|
||||
MembershipFilter,
|
||||
MuteRuleFilter,
|
||||
ProcessorFilter,
|
||||
ProviderFilter,
|
||||
ProviderGroupFilter,
|
||||
@@ -115,6 +117,7 @@ from api.models import (
|
||||
LighthouseProviderModels,
|
||||
LighthouseTenantConfiguration,
|
||||
Membership,
|
||||
MuteRule,
|
||||
Processor,
|
||||
Provider,
|
||||
ProviderGroup,
|
||||
@@ -175,6 +178,9 @@ from api.v1.serializers import (
|
||||
LighthouseTenantConfigSerializer,
|
||||
LighthouseTenantConfigUpdateSerializer,
|
||||
MembershipSerializer,
|
||||
MuteRuleCreateSerializer,
|
||||
MuteRuleSerializer,
|
||||
MuteRuleUpdateSerializer,
|
||||
OverviewFindingSerializer,
|
||||
OverviewProviderCountSerializer,
|
||||
OverviewProviderSerializer,
|
||||
@@ -414,6 +420,12 @@ 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)
|
||||
|
||||
@@ -4311,28 +4323,30 @@ class LighthouseConfigViewSet(BaseRLSViewSet):
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["Lighthouse AI"],
|
||||
summary="List all LLM provider configs",
|
||||
summary="List all LLM provider configurations",
|
||||
description="Retrieve all LLM provider configurations for the current tenant",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["Lighthouse AI"],
|
||||
summary="Retrieve LLM provider config",
|
||||
summary="Retrieve LLM provider configuration",
|
||||
description="Get details for a specific provider configuration in the current tenant.",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["Lighthouse AI"],
|
||||
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.",
|
||||
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.",
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["Lighthouse AI"],
|
||||
summary="Update LLM provider config",
|
||||
summary="Update LLM provider configuration",
|
||||
description="Partially update a provider configuration (e.g., base_url, is_active).",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["Lighthouse AI"],
|
||||
summary="Delete LLM provider config",
|
||||
description="Delete a provider configuration. Any tenant defaults that reference this provider are cleared during deletion.",
|
||||
summary="Delete LLM provider configuration",
|
||||
description="Delete a provider configuration. Any tenant defaults that reference this provider are cleared "
|
||||
"during deletion.",
|
||||
),
|
||||
)
|
||||
class LighthouseProviderConfigViewSet(BaseRLSViewSet):
|
||||
@@ -4397,16 +4411,6 @@ 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(
|
||||
@@ -4428,7 +4432,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.",
|
||||
description="Fetch available models for this provider configuration and upsert into catalog. Supports OpenAI, OpenAI-compatible, and AWS Bedrock providers.",
|
||||
request=None,
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
)
|
||||
@@ -4440,16 +4444,6 @@ 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(
|
||||
@@ -4705,3 +4699,95 @@ 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,
|
||||
)
|
||||
@@ -23,6 +23,7 @@ from api.models import (
|
||||
Invitation,
|
||||
LighthouseConfiguration,
|
||||
Membership,
|
||||
MuteRule,
|
||||
Processor,
|
||||
Provider,
|
||||
ProviderGroup,
|
||||
@@ -1425,6 +1426,34 @@ 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,6 +1,8 @@
|
||||
from typing import Dict, Set
|
||||
from typing import Dict
|
||||
|
||||
import boto3
|
||||
import openai
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from api.models import LighthouseProviderConfiguration, LighthouseProviderModels
|
||||
@@ -30,16 +32,71 @@ 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 (str): The primary key of the `LighthouseProviderConfiguration`
|
||||
provider_config_id: The primary key of the `LighthouseProviderConfiguration`
|
||||
to validate.
|
||||
|
||||
Returns:
|
||||
@@ -55,42 +112,238 @@ 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:
|
||||
client = openai.OpenAI(api_key=api_key)
|
||||
_ = client.models.list()
|
||||
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
|
||||
provider_cfg.is_active = True
|
||||
provider_cfg.save()
|
||||
return {"connected": True, "error": None}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("OpenAI connection check failed: %s", str(e))
|
||||
logger.warning(
|
||||
"%s connection check failed: %s", provider_cfg.provider_type, 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.
|
||||
|
||||
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.
|
||||
Fetches the current list of models from the provider, upserts entries into
|
||||
`LighthouseProviderModels`, and deletes stale entries no longer returned.
|
||||
|
||||
Args:
|
||||
provider_config_id (str): The primary key of the `LighthouseProviderConfiguration`
|
||||
provider_config_id: The primary key of the `LighthouseProviderConfiguration`
|
||||
whose models should be refreshed.
|
||||
|
||||
Returns:
|
||||
@@ -104,45 +357,81 @@ 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] = {}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
# Fetch models from the appropriate provider
|
||||
try:
|
||||
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))
|
||||
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),
|
||||
)
|
||||
return {"created": 0, "updated": 0, "deleted": 0, "error": str(e)}
|
||||
|
||||
# Upsert models into the catalog
|
||||
created = 0
|
||||
updated = 0
|
||||
|
||||
for model_id in fetched_ids:
|
||||
for model_id, model_name in fetched_models.items():
|
||||
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_id, # OpenAI doesn't return a separate display name
|
||||
"model_name": model_name,
|
||||
"default_parameters": {},
|
||||
},
|
||||
)
|
||||
@@ -156,7 +445,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_ids)
|
||||
.exclude(model_id__in=fetched_models.keys())
|
||||
.delete()
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
}
|
||||
@@ -30,6 +30,7 @@ from api.exceptions import ProviderConnectionError
|
||||
from api.models import (
|
||||
ComplianceRequirementOverview,
|
||||
Finding,
|
||||
MuteRule,
|
||||
Processor,
|
||||
Provider,
|
||||
Resource,
|
||||
@@ -301,6 +302,20 @@ 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:
|
||||
@@ -449,11 +464,22 @@ def perform_prowler_scan(
|
||||
if not last_first_seen_at:
|
||||
last_first_seen_at = datetime.now(tz=timezone.utc)
|
||||
|
||||
# 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
|
||||
# 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]
|
||||
|
||||
# Increment failed_findings_count cache if the finding status is FAIL and not muted
|
||||
if status == FindingStatus.FAIL and not finding.muted:
|
||||
if status == FindingStatus.FAIL and not is_muted:
|
||||
resource_uid = finding.resource_uid
|
||||
resource_failed_findings_cache[resource_uid] += 1
|
||||
|
||||
@@ -472,7 +498,8 @@ def perform_prowler_scan(
|
||||
check_id=finding.check_id,
|
||||
scan=scan_instance,
|
||||
first_seen_at=last_first_seen_at,
|
||||
muted=finding.muted,
|
||||
muted=is_muted,
|
||||
muted_at=datetime.now(tz=timezone.utc) if is_muted else None,
|
||||
muted_reason=muted_reason,
|
||||
compliance=finding.compliance,
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ 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,
|
||||
@@ -681,3 +682,25 @@ 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)
|
||||
@@ -0,0 +1,532 @@
|
||||
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)
|
||||
@@ -18,7 +18,15 @@ from tasks.utils import CustomEncoder
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.exceptions import ProviderConnectionError
|
||||
from api.models import Finding, Provider, Resource, Scan, StateChoices, StatusChoices
|
||||
from api.models import (
|
||||
Finding,
|
||||
MuteRule,
|
||||
Provider,
|
||||
Resource,
|
||||
Scan,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
)
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
|
||||
@@ -739,6 +747,561 @@ 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,16 +1,24 @@
|
||||
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
|
||||
from api.models import (
|
||||
Integration,
|
||||
LighthouseProviderConfiguration,
|
||||
LighthouseProviderModels,
|
||||
)
|
||||
|
||||
|
||||
# TODO Move this to outputs/reports jobs
|
||||
@@ -1097,3 +1105,363 @@ 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
|
||||
@@ -1,335 +0,0 @@
|
||||
# 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)**
|
||||
@@ -1,182 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,124 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Retrieve Compliance Attributes"
|
||||
api: "GET /api/v1/compliance-overviews/attributes"
|
||||
description: "Get compliance framework attributes."
|
||||
---
|
||||
|
||||
Retrieve detailed attributes for compliance frameworks.
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
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"
|
||||
\`\`\`
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Retrieve Compliance Requirements"
|
||||
api: "GET /api/v1/compliance-overviews/requirements"
|
||||
description: "Get compliance requirements details."
|
||||
---
|
||||
|
||||
Fetch specific requirements for compliance frameworks.
|
||||
@@ -1,109 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,287 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Create Integration"
|
||||
api: "POST /api/v1/integrations"
|
||||
description: "Configure a new integration."
|
||||
---
|
||||
|
||||
Add integration with external systems.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Delete Integration"
|
||||
api: "DELETE /api/v1/integrations/{id}"
|
||||
description: "Remove an integration."
|
||||
---
|
||||
|
||||
Delete integration configuration.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Create Jira Dispatch"
|
||||
api: "POST /api/v1/integrations/{id}/jira/dispatches"
|
||||
description: "Send findings to Jira."
|
||||
---
|
||||
|
||||
Dispatch findings to Jira for tracking.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "List Integrations"
|
||||
api: "GET /api/v1/integrations"
|
||||
description: "Retrieve configured integrations."
|
||||
---
|
||||
|
||||
Get all configured integrations (S3, Security Hub, Jira, etc).
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Retrieve Integration"
|
||||
api: "GET /api/v1/integrations/{id}"
|
||||
description: "Get integration details."
|
||||
---
|
||||
|
||||
Fetch specific integration configuration.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Update Integration"
|
||||
api: "PATCH /api/v1/integrations/{id}"
|
||||
description: "Update integration settings."
|
||||
---
|
||||
|
||||
Modify integration configuration.
|
||||
@@ -1,121 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "List Lighthouse Config (Legacy)"
|
||||
api: "GET /api/v1/lighthouse/configuration"
|
||||
description: "Legacy configuration endpoint."
|
||||
---
|
||||
|
||||
Legacy endpoint for configuration.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Update Lighthouse Config (Legacy)"
|
||||
api: "PATCH /api/v1/lighthouse/configuration"
|
||||
description: "Update legacy configuration."
|
||||
---
|
||||
|
||||
Legacy endpoint for updating configuration.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Create Lighthouse Configuration"
|
||||
api: "POST /api/v1/lighthouse/configurations"
|
||||
description: "Create AI configuration profile."
|
||||
---
|
||||
|
||||
Configure Lighthouse AI settings.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Delete Lighthouse Configuration"
|
||||
api: "DELETE /api/v1/lighthouse/configurations/{id}"
|
||||
description: "Remove configuration profile."
|
||||
---
|
||||
|
||||
Delete Lighthouse configuration.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "List Lighthouse Configurations"
|
||||
api: "GET /api/v1/lighthouse/configurations"
|
||||
description: "Retrieve Lighthouse AI configurations."
|
||||
---
|
||||
|
||||
Get Lighthouse configuration profiles.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Retrieve Lighthouse Configuration"
|
||||
api: "GET /api/v1/lighthouse/configurations/{id}"
|
||||
description: "Get configuration details."
|
||||
---
|
||||
|
||||
Fetch Lighthouse configuration profile.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Test Lighthouse Configuration"
|
||||
api: "POST /api/v1/lighthouse/configurations/{id}/connection"
|
||||
description: "Test configuration."
|
||||
---
|
||||
|
||||
Verify Lighthouse configuration works.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Update Lighthouse Configuration"
|
||||
api: "PATCH /api/v1/lighthouse/configurations/{id}"
|
||||
description: "Update configuration profile."
|
||||
---
|
||||
|
||||
Modify Lighthouse AI settings.
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "List Lighthouse Models"
|
||||
api: "GET /api/v1/lighthouse/models"
|
||||
description: "Retrieve available AI models."
|
||||
---
|
||||
|
||||
Get list of available LLM models.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Retrieve Lighthouse Model"
|
||||
api: "GET /api/v1/lighthouse/models/{id}"
|
||||
description: "Get AI model details."
|
||||
---
|
||||
|
||||
Fetch information about a specific model.
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Delete Lighthouse Provider"
|
||||
api: "DELETE /api/v1/lighthouse/providers/{id}"
|
||||
description: "Remove AI provider."
|
||||
---
|
||||
|
||||
Delete LLM provider configuration.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
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).
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Retrieve Lighthouse Provider"
|
||||
api: "GET /api/v1/lighthouse/providers/{id}"
|
||||
description: "Get AI provider details."
|
||||
---
|
||||
|
||||
Fetch LLM provider configuration.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Test Lighthouse Provider Connection"
|
||||
api: "POST /api/v1/lighthouse/providers/{id}/connection"
|
||||
description: "Test AI provider connectivity."
|
||||
---
|
||||
|
||||
Verify LLM provider is properly configured.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
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
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Findings Severity Overview"
|
||||
api: "GET /api/v1/overviews/findings-severity"
|
||||
description: "Get overview statistics for findings-severity."
|
||||
---
|
||||
|
||||
Retrieve aggregated statistics and metrics.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Findings Overview"
|
||||
api: "GET /api/v1/overviews/findings"
|
||||
description: "Get overview statistics for findings."
|
||||
---
|
||||
|
||||
Retrieve aggregated statistics and metrics.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Providers Count Overview"
|
||||
api: "GET /api/v1/overviews/providers-count"
|
||||
description: "Get overview statistics for providers-count."
|
||||
---
|
||||
|
||||
Retrieve aggregated statistics and metrics.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Providers Overview"
|
||||
api: "GET /api/v1/overviews/providers"
|
||||
description: "Get overview statistics for providers."
|
||||
---
|
||||
|
||||
Retrieve aggregated statistics and metrics.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Services Overview"
|
||||
api: "GET /api/v1/overviews/services"
|
||||
description: "Get overview statistics for services."
|
||||
---
|
||||
|
||||
Retrieve aggregated statistics and metrics.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Create Processor"
|
||||
api: "POST /api/v1/processors"
|
||||
description: "create processor configuration."
|
||||
---
|
||||
|
||||
Processor operations for result processing.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Delete Processor"
|
||||
api: "DELETE /api/v1/processors/{id}"
|
||||
description: "delete processor configuration."
|
||||
---
|
||||
|
||||
Processor operations for result processing.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "List Processor"
|
||||
api: "GET /api/v1/processors"
|
||||
description: "list processor configuration."
|
||||
---
|
||||
|
||||
Processor operations for result processing.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Retrieve Processor"
|
||||
api: "PATCH /api/v1/processors/{id}"
|
||||
description: "retrieve processor configuration."
|
||||
---
|
||||
|
||||
Processor operations for result processing.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Update Processor"
|
||||
api: "PATCH /api/v1/processors/{id}"
|
||||
description: "update processor configuration."
|
||||
---
|
||||
|
||||
Processor operations for result processing.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
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"
|
||||
\`\`\`
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Retrieve Provider Group"
|
||||
api: "GET /api/v1/provider-groups/{id}"
|
||||
description: "Get provider group details."
|
||||
---
|
||||
|
||||
Fetch information about a specific provider group.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
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
Reference in new issue
Block a user