Compare commits

..

84 Commits

Author SHA1 Message Date
pedrooot 77089eba57 docs(api): use mintlify for API specs 2025-10-30 18:58:05 +01:00
Pedro Martín f831171a21 feat(compliance): add C5 for GCP provider (#9097) 2025-10-30 15:55:07 +01:00
César Arroba 2740d73fe7 chore(github): improve slack notification action (#9100) 2025-10-30 15:32:14 +01:00
Rubén De la Torre Vico 1c906b37cd chore(gcp): enhance metadata for artifacts service (#9088)
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-30 10:30:27 -04:00
Sergio Garcia 98056b7c85 fix(ui): auto-populate OCI tenancy from provider UID in credentials form (#9074)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2025-10-30 09:47:15 -04:00
Rubén De la Torre Vico f15ef0d16c chore(aws): enhance metadata for elasticbeanstalk service (#8934)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-30 09:38:42 -04:00
Alan Buscaglia c42ce6242f refactor: improve React 19 event typing in select component (#9043) 2025-10-30 14:20:26 +01:00
Alan Buscaglia 702d652de1 feat: add comprehensive CSS theme variables for semantic color system (#9060) 2025-10-30 14:18:47 +01:00
Alan Buscaglia fff02073cf feat(overview): findings visualizations tabs component (#8999) 2025-10-30 14:18:14 +01:00
Rubén De la Torre Vico 23e3ea4a41 chore(aws): enhance metadata for cloudwatch service (#8848)
Co-authored-by: HugoPBrito <hugopbrit@gmail.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2025-10-30 14:08:18 +01:00
Chandrapal Badshah f9afb50ed9 fix(api): standardize JSON:API resource types for Lighthouse endpoints (#9085)
Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
2025-10-30 13:36:51 +01:00
Andoni Alonso 3b95aad6ce fix(github): use members endpoint to verify author (#9086) 2025-10-30 13:25:00 +01:00
Andoni Alonso ac5737d8c4 docs(threatscore): banner only available in Cloud/App (#9095) 2025-10-30 13:23:48 +01:00
César Arroba a452c8c3eb chore(github): send slack message on container release (#9089) 2025-10-30 13:20:54 +01:00
Adrián Jesús Peña Rodríguez aa8be0b2fe fix(api): update database routing logic in MainRouter (#9080) 2025-10-30 12:30:53 +01:00
Rubén De la Torre Vico 46bf8e0fef chore(aws): enhance metadata for elasticache service (#8933)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-10-30 11:39:01 +01:00
Andoni Alonso c0df0cd1a8 chore(github): run community label only in main repo (#9083) 2025-10-30 10:16:55 +01:00
César Arroba 80d58a7b50 chore(github): separate mcp pr jobs in different actions (#9079) 2025-10-30 10:03:05 +01:00
César Arroba 2c28d74598 chore(github): separate api pr jobs in different actions (#9078) 2025-10-30 10:02:53 +01:00
César Arroba 4feab1be55 chore(github): separate ui pr jobs in different actions (#9076) 2025-10-30 10:02:41 +01:00
César Arroba 5bc9b09490 chore(github): separate sdk pr jobs in different actions (#9075) 2025-10-30 10:02:22 +01:00
Pedro Martín fcf817618a feat(compliance): add c5 azure base (#9081)
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-30 09:54:50 +01:00
Rubén De la Torre Vico cad97f25ac chore(aws): enhance metadata for eks service (#8890)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-10-30 09:49:00 +01:00
Rubén De la Torre Vico b854563854 fix(emr): invalid JSON trailing comma (#9082) 2025-10-30 09:38:48 +01:00
Rubén De la Torre Vico 573975f3fe chore(aws): enhance metadata for emr service (#9002)
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-29 15:37:14 -04:00
Rubén De la Torre Vico f4081f92a1 chore(aws): enhance metadata for eventbridge service (#9003)
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-29 15:14:36 -04:00
Rubén De la Torre Vico 374496e7ff chore(aws): enhance metadata for firehose service (#9004)
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-29 14:18:37 -04:00
Rubén De la Torre Vico 2a9c2b926d chore(aws): enhance metadata for fms service (#9005)
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-29 14:15:00 -04:00
Pedro Martín f2f1e6bce6 feat(dashboard): update logo (#9040) 2025-10-29 14:12:56 -04:00
Rubén De la Torre Vico 25c823076f chore(aws): enhance metadata for fsx service (#9006)
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-29 14:11:53 -04:00
Rubén De la Torre Vico 6ff559c0d4 chore(aws): enhance metadata for glacier service (#9007) 2025-10-29 14:03:14 -04:00
Andoni Alonso 899db55f56 chore(github): refactor community labeler (#9077) 2025-10-29 17:58:48 +01:00
Andoni Alonso 22d801ade2 chore(github): refactor community labeler (#9073) 2025-10-29 16:40:56 +01:00
César Arroba 1dc6d41198 chore: revert files ignore action removal (#9070) 2025-10-29 15:24:34 +01:00
César Arroba 456712a0ef chore(github): fix trivy action (#9066) 2025-10-29 14:51:49 +01:00
Hugo Pereira Brito 885ee62062 fix(m365): admincenter service unnecessary msgraph calls and repeated resource_id (#9019)
Co-authored-by: César Arroba <cesar@prowler.com>
2025-10-29 14:50:25 +01:00
César Arroba bbeccaf085 chore(github): improve trivy scan time (#9065) 2025-10-29 14:40:48 +01:00
César Arroba d1aca5641a chore(github): increase sdk tests timeout to 120m (#9062) 2025-10-29 13:47:10 +01:00
Pepe Fagoaga 3b7eba64aa chore: remove not used admin interface (#9059) 2025-10-29 17:37:09 +05:45
César Arroba e9e0797642 chore(github): improve container actions (#9061) 2025-10-29 12:42:53 +01:00
lydiavilchez aaa5abdead feat(gcp): add cloudstorage_bucket_soft_delete_enabled check (#9028)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-10-29 12:02:46 +01:00
César Arroba 0a2749b716 chore(github): improve SDK container build and push action (#9034) 2025-10-29 12:00:15 +01:00
César Arroba 8f8bf63086 chore(github): improve UI container build and push action (#9033) 2025-10-29 11:59:54 +01:00
César Arroba ea27817a2c chore(github): improve API container build and push action (#9032) 2025-10-29 11:59:39 +01:00
César Arroba 9068e6bcd0 chore(github): improve sdk pull request action (#9027) 2025-10-29 11:10:08 +01:00
César Arroba a4907d8098 chore(github): improve UI pull request action (#9029) 2025-10-29 10:58:57 +01:00
César Arroba caee7830a5 chore(github): improve SDK refresh AWS regions action (#9031) 2025-10-29 10:35:30 +01:00
César Arroba 65d2989bea chore(github): improve SDK PyPi release action (#9030) 2025-10-29 10:35:20 +01:00
Adrián Jesús Peña Rodríguez 6c34945829 feat(api): enhance overview provider aggregation and resource counting (#9053) 2025-10-29 10:31:40 +01:00
César Arroba ce859ddd1f chore(github): improve bump version action (#9024) 2025-10-29 10:26:31 +01:00
Sergio Garcia 0ca059b45b feat(ui): add Oracle Cloud Infrastructure (OCI) provider support (#8984) 2025-10-28 17:30:12 -04:00
Sergio Garcia dad100b87a feat(api): add Oracle Cloud Infrastructure (OCI) provider support (#8927)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-10-28 16:43:24 +01:00
Adrián Jesús Peña Rodríguez 662296aa0e feat(api): enhance provider filtering and pagination capabilities (#8975) 2025-10-28 16:36:35 +01:00
Rubén De la Torre Vico b6d49416f0 docs(mcp): add specific tutorial per famouse MCP Host (#9036) 2025-10-28 16:36:20 +01:00
Pepe Fagoaga 42be77e82e fix(backport): Run ir PR is closed and labeled (#9047) 2025-10-28 19:21:29 +05:45
Daniel Barranquero 63169289b0 fix(ec2): AttributeError in ec2_instance_with_outdated_ami check (#9046) 2025-10-28 09:13:44 -04:00
lydiavilchez 43d310356d feat(gcp): add cloudstorage_bucket_versioning_enabled check (#9014)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-10-28 13:20:59 +01:00
Pedro Martín 59ae503681 fix(compliance): handle timestamp when transforming CCC findings (#9042) 2025-10-28 12:45:04 +01:00
Rubén De la Torre Vico bd62f56df4 chore(aws): enhance metadata for dynamodb service (#8871)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-10-28 12:08:01 +01:00
Alejandro Bailo 90fbad16b9 feat: add risk severity chart to new overview page (#9041) 2025-10-28 12:07:19 +01:00
Alan Buscaglia affd0c5ffb chore: upgrade React to 19.2.0 and eslint-plugin-react-hooks to 7.0.1 (#9039)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2025-10-28 11:50:07 +01:00
StylusFrost 929bbe3550 test(ui): add AWS provider management E2E tests (#8948) 2025-10-28 11:49:41 +01:00
Andoni Alonso eb7ef4a8b9 chore(github): update dev guide docs link (#9044) 2025-10-28 11:45:30 +01:00
Rubén De la Torre Vico 017e19ac18 chore(aws): enhance metadata for drs service (#8870)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-10-28 10:23:47 +01:00
Alejandro Bailo be7680786a feat: new overview filters (#9013)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2025-10-28 08:44:46 +01:00
SeongYong Choi efba5d2a8d feat(codepipeline): add new check codepipeline_project_repo_private (#5915)
Co-authored-by: MrCloudSec <hello@mistercloudsec.com>
2025-10-27 18:55:36 -04:00
Alan Buscaglia 44431a56de feat(api-keys): add read docs api key (#8947) 2025-10-27 18:06:44 +01:00
Andoni Alonso 969ca8863a chore(github): use gh instead of github-script to lable community (#9035) 2025-10-27 17:47:16 +01:00
Rubén De la Torre Vico 03c6f98db4 chore(aws): enhance metadata for directconnect service (#8855)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-10-27 16:51:13 +01:00
Chandrapal Badshah 8ebefb8aa1 feat: add lighthouse support for multiple providers (#8772)
Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-10-27 16:23:54 +01:00
Andoni Alonso c3694fdc5b chore(github): add label to community contributed PRs (#9009) 2025-10-27 14:48:27 +01:00
Prowler Bot df10bc0c4c chore(regions_update): Changes in regions for AWS services (#9022)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2025-10-27 09:35:35 -04:00
Pedro Martín e694b0f634 fix(gcp): set unknown for resource name under metric resources (#9023) 2025-10-27 14:19:15 +01:00
Rubén De la Torre Vico 81e3f87003 chore: add AGENTS.md for Prowler SDK (#9017)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2025-10-27 13:47:14 +01:00
César Arroba 7ffe2aeec9 chore(github): improve ui codeql action and config (#9026) 2025-10-27 13:23:54 +01:00
César Arroba 672aa6eb2f chore(github): improve sdk codeql action and config (#9025) 2025-10-27 13:23:18 +01:00
StylusFrost 2e999f55f9 test(ui): add Playwright E2E testing guidelines and folder structure (#8899)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2025-10-27 13:21:49 +01:00
StylusFrost 18998b8867 test(ui): E2E Test - New user sign-up/registration (#8895)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-10-27 11:25:34 +01:00
Alex K ff4a186df6 feat(github): add organization base repository permission strict check (CIS GitHub 1.3.8) (#8785)
Co-authored-by: akorshak-afg <alex.korshak@afg.org>
Co-authored-by: Sergio Garcia <sergargar1@gmail.com>
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
2025-10-27 09:45:50 +01:00
Pepe Fagoaga b8dab5e0ed docs: add version label in pages (#9020) 2025-10-27 09:20:37 +01:00
César Arroba 0b3142f7a8 chore(mcp): MCP pull request action (#8990) 2025-10-24 12:44:57 +02:00
César Arroba f5dc0c9ee0 chore(github): fix prepare release action (#8998) 2025-10-24 12:44:32 +02:00
Prowler Bot a230809095 chore(release): Bump version to v5.14.0 (#9015)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2025-10-24 16:16:35 +05:45
Andoni Alonso e6d1b5639b chore(github): include roadmap in features request template (#9000) 2025-10-23 15:06:34 +02:00
407 changed files with 54561 additions and 3115 deletions
-2
View File
@@ -35,8 +35,6 @@ POSTGRES_DB=prowler_db
# POSTGRES_REPLICA_USER=prowler
# POSTGRES_REPLICA_PASSWORD=postgres
# POSTGRES_REPLICA_DB=prowler_db
# POSTGRES_REPLICA_MAX_ATTEMPTS=3
# POSTGRES_REPLICA_RETRY_BASE_DELAY=0.5
# Celery-Prowler task settings
TASK_RETRY_DELAY_SECONDS=0.1
+8 -14
View File
@@ -12,7 +12,7 @@ inputs:
required: false
default: ''
step-outcome:
description: 'Outcome of a step to determine status (success/failure) - automatically sets STATUS_TEXT and STATUS_COLOR env vars'
description: 'Outcome of a step to determine status (success/failure) - automatically sets STATUS_EMOJI, STATUS_TEXT, and STATUS_COLOR env vars'
required: false
default: ''
outputs:
@@ -27,41 +27,35 @@ runs:
shell: bash
run: |
if [[ "${{ inputs.step-outcome }}" == "success" ]]; then
echo "STATUS_TEXT=Completed" >> $GITHUB_ENV
echo "STATUS_COLOR=#6aa84f" >> $GITHUB_ENV
echo "STATUS_EMOJI=[✓]" >> $GITHUB_ENV
echo "STATUS_TEXT=succeeded" >> $GITHUB_ENV
echo "STATUS_COLOR=6aa84f" >> $GITHUB_ENV
elif [[ "${{ inputs.step-outcome }}" == "failure" ]]; then
echo "STATUS_TEXT=Failed" >> $GITHUB_ENV
echo "STATUS_COLOR=#fc3434" >> $GITHUB_ENV
echo "STATUS_EMOJI=[✗]" >> $GITHUB_ENV
echo "STATUS_TEXT=failed" >> $GITHUB_ENV
echo "STATUS_COLOR=fc3434" >> $GITHUB_ENV
else
# No outcome provided - pending/in progress state
echo "STATUS_COLOR=#dbab09" >> $GITHUB_ENV
echo "STATUS_COLOR=dbab09" >> $GITHUB_ENV
fi
- name: Send Slack notification (new message)
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,17 +1,4 @@
{
"channel": "${{ env.SLACK_CHANNEL_ID }}",
"attachments": [
{
"color": "${{ env.STATUS_COLOR }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Status:*\n${{ env.STATUS_TEXT }}\n\n${{ env.COMPONENT }} container release ${{ env.RELEASE_TAG }} push ${{ env.STATUS_TEXT }}\n\n<${{ env.GITHUB_SERVER_URL }}/${{ env.GITHUB_REPOSITORY }}/actions/runs/${{ env.GITHUB_RUN_ID }}|View run>"
}
}
]
}
]
}
"channel": "$SLACK_CHANNEL_ID",
"text": "$STATUS_EMOJI $COMPONENT container release $RELEASE_TAG push $STATUS_TEXT <$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID|View run>"
}
@@ -1,17 +1,4 @@
{
"channel": "${{ env.SLACK_CHANNEL_ID }}",
"attachments": [
{
"color": "${{ env.STATUS_COLOR }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Status:*\nStarted\n\n${{ env.COMPONENT }} container release ${{ env.RELEASE_TAG }} push started...\n\n<${{ env.GITHUB_SERVER_URL }}/${{ env.GITHUB_REPOSITORY }}/actions/runs/${{ env.GITHUB_RUN_ID }}|View run>"
}
}
]
}
]
"channel": "$SLACK_CHANNEL_ID",
"text": "$COMPONENT container release $RELEASE_TAG push started... <$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID|View run>"
}
+6 -3
View File
@@ -5,10 +5,16 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'api/**'
- '.github/workflows/api-code-quality.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'api/**'
- '.github/workflows/api-code-quality.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -39,9 +45,6 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: |
api/**
.github/workflows/api-code-quality.yml
files_ignore: |
api/docs/**
api/README.md
+1 -1
View File
@@ -25,7 +25,7 @@ concurrency:
cancel-in-progress: true
jobs:
api-analyze:
analyze:
name: CodeQL Security Analysis
runs-on: ubuntu-latest
timeout-minutes: 30
+6 -1
View File
@@ -5,10 +5,16 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'api/**'
- '.github/workflows/api-container-checks.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'api/**'
- '.github/workflows/api-container-checks.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -58,7 +64,6 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: api/**
files_ignore: |
api/docs/**
api/README.md
+6 -3
View File
@@ -5,10 +5,16 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'api/**'
- '.github/workflows/api-security.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'api/**'
- '.github/workflows/api-security.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -39,9 +45,6 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: |
api/**
.github/workflows/api-security.yml
files_ignore: |
api/docs/**
api/README.md
+6 -3
View File
@@ -5,10 +5,16 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'api/**'
- '.github/workflows/api-tests.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'api/**'
- '.github/workflows/api-tests.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -79,9 +85,6 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: |
api/**
.github/workflows/api-tests.yml
files_ignore: |
api/docs/**
api/README.md
+1 -3
View File
@@ -40,6 +40,4 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
echo "Adding 'community' label to PR #$PR_NUMBER"
gh api /repos/${{ github.repository }}/issues/${{ github.event.number }}/labels \
-X POST \
-f labels[]='community'
gh pr edit "$PR_NUMBER" --add-label community
+6 -1
View File
@@ -5,10 +5,16 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'mcp_server/**'
- '.github/workflows/mcp-container-checks.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'mcp_server/**'
- '.github/workflows/mcp-container-checks.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -57,7 +63,6 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: mcp_server/**
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
+80 -70
View File
@@ -47,7 +47,7 @@ jobs:
git config --global user.name 'prowler-bot'
git config --global user.email '179230569+prowler-bot@users.noreply.github.com'
- name: Parse version and determine branch
- name: Parse version and read changelogs
run: |
# Validate version format (reusing pattern from sdk-bump-version.yml)
if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
@@ -64,83 +64,66 @@ jobs:
BRANCH_NAME="v${MAJOR_VERSION}.${MINOR_VERSION}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_ENV}"
# Function to extract the latest version from changelog
extract_latest_version() {
local changelog_file="$1"
if [ -f "$changelog_file" ]; then
# Extract the first version entry (most recent) from changelog
# Format: ## [version] (1.2.3) or ## [vversion] (v1.2.3)
local version=$(grep -m 1 '^## \[' "$changelog_file" | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
echo "$version"
else
echo ""
fi
}
# Read actual versions from changelogs (source of truth)
UI_VERSION=$(extract_latest_version "ui/CHANGELOG.md")
API_VERSION=$(extract_latest_version "api/CHANGELOG.md")
SDK_VERSION=$(extract_latest_version "prowler/CHANGELOG.md")
MCP_VERSION=$(extract_latest_version "mcp_server/CHANGELOG.md")
echo "UI_VERSION=${UI_VERSION}" >> "${GITHUB_ENV}"
echo "API_VERSION=${API_VERSION}" >> "${GITHUB_ENV}"
echo "SDK_VERSION=${SDK_VERSION}" >> "${GITHUB_ENV}"
echo "MCP_VERSION=${MCP_VERSION}" >> "${GITHUB_ENV}"
if [ -n "$UI_VERSION" ]; then
echo "Read UI version from changelog: $UI_VERSION"
else
echo "Warning: No UI version found in ui/CHANGELOG.md"
fi
if [ -n "$API_VERSION" ]; then
echo "Read API version from changelog: $API_VERSION"
else
echo "Warning: No API version found in api/CHANGELOG.md"
fi
if [ -n "$SDK_VERSION" ]; then
echo "Read SDK version from changelog: $SDK_VERSION"
else
echo "Warning: No SDK version found in prowler/CHANGELOG.md"
fi
if [ -n "$MCP_VERSION" ]; then
echo "Read MCP version from changelog: $MCP_VERSION"
else
echo "Warning: No MCP version found in mcp_server/CHANGELOG.md"
fi
echo "Prowler version: $PROWLER_VERSION"
echo "Branch name: $BRANCH_NAME"
echo "UI version: $UI_VERSION"
echo "API version: $API_VERSION"
echo "SDK version: $SDK_VERSION"
echo "MCP version: $MCP_VERSION"
echo "Is minor release: $([ $PATCH_VERSION -eq 0 ] && echo 'true' || echo 'false')"
else
echo "Invalid version syntax: '$PROWLER_VERSION' (must be N.N.N)" >&2
exit 1
fi
- name: Checkout release branch
run: |
echo "Checking out branch $BRANCH_NAME for release $PROWLER_VERSION..."
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
echo "Branch $BRANCH_NAME exists locally, checking out..."
git checkout "$BRANCH_NAME"
elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then
echo "Branch $BRANCH_NAME exists remotely, checking out..."
git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME"
else
echo "ERROR: Branch $BRANCH_NAME does not exist. For minor releases (X.Y.0), create it manually first. For patch releases (X.Y.Z), the branch should already exist."
exit 1
fi
- name: Read changelog versions from release branch
run: |
# Function to extract the latest version from changelog
extract_latest_version() {
local changelog_file="$1"
if [ -f "$changelog_file" ]; then
# Extract the first version entry (most recent) from changelog
# Format: ## [version] (1.2.3) or ## [vversion] (v1.2.3)
local version=$(grep -m 1 '^## \[' "$changelog_file" | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
echo "$version"
else
echo ""
fi
}
# Read actual versions from changelogs (source of truth)
UI_VERSION=$(extract_latest_version "ui/CHANGELOG.md")
API_VERSION=$(extract_latest_version "api/CHANGELOG.md")
SDK_VERSION=$(extract_latest_version "prowler/CHANGELOG.md")
MCP_VERSION=$(extract_latest_version "mcp_server/CHANGELOG.md")
echo "UI_VERSION=${UI_VERSION}" >> "${GITHUB_ENV}"
echo "API_VERSION=${API_VERSION}" >> "${GITHUB_ENV}"
echo "SDK_VERSION=${SDK_VERSION}" >> "${GITHUB_ENV}"
echo "MCP_VERSION=${MCP_VERSION}" >> "${GITHUB_ENV}"
if [ -n "$UI_VERSION" ]; then
echo "Read UI version from changelog: $UI_VERSION"
else
echo "Warning: No UI version found in ui/CHANGELOG.md"
fi
if [ -n "$API_VERSION" ]; then
echo "Read API version from changelog: $API_VERSION"
else
echo "Warning: No API version found in api/CHANGELOG.md"
fi
if [ -n "$SDK_VERSION" ]; then
echo "Read SDK version from changelog: $SDK_VERSION"
else
echo "Warning: No SDK version found in prowler/CHANGELOG.md"
fi
if [ -n "$MCP_VERSION" ]; then
echo "Read MCP version from changelog: $MCP_VERSION"
else
echo "Warning: No MCP version found in mcp_server/CHANGELOG.md"
fi
echo "UI version: $UI_VERSION"
echo "API version: $API_VERSION"
echo "SDK version: $SDK_VERSION"
echo "MCP version: $MCP_VERSION"
- name: Extract and combine changelog entries
run: |
set -e
@@ -272,6 +255,21 @@ jobs:
echo "Combined changelog preview:"
cat combined_changelog.md
- name: Checkout release branch for patch release
if: ${{ env.PATCH_VERSION != '0' }}
run: |
echo "Patch release detected, checking out existing branch $BRANCH_NAME..."
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
echo "Branch $BRANCH_NAME exists locally, checking out..."
git checkout "$BRANCH_NAME"
elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then
echo "Branch $BRANCH_NAME exists remotely, checking out..."
git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME"
else
echo "ERROR: Branch $BRANCH_NAME should exist for patch release $PROWLER_VERSION"
exit 1
fi
- name: Verify SDK version in pyproject.toml
run: |
CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/' | tr -d '[:space:]')
@@ -325,6 +323,18 @@ jobs:
fi
echo "✓ api/src/backend/api/v1/views.py version: $CURRENT_API_VERSION"
- name: Checkout release branch for minor release
if: ${{ env.PATCH_VERSION == '0' }}
run: |
echo "Minor release detected (patch = 0), checking out existing branch $BRANCH_NAME..."
if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then
echo "Branch $BRANCH_NAME exists remotely, checking out..."
git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME"
else
echo "ERROR: Branch $BRANCH_NAME should exist for minor release $PROWLER_VERSION. Please create it manually first."
exit 1
fi
- name: Update API prowler dependency for minor release
if: ${{ env.PATCH_VERSION == '0' }}
run: |
+12 -3
View File
@@ -5,10 +5,22 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'tests/**'
- 'pyproject.toml'
- 'poetry.lock'
- '.github/workflows/sdk-code-quality.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'tests/**'
- 'pyproject.toml'
- 'poetry.lock'
- '.github/workflows/sdk-code-quality.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -16,7 +28,6 @@ concurrency:
jobs:
sdk-code-quality:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -37,9 +48,7 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: ./**
files_ignore: |
.github/**
prowler/CHANGELOG.md
docs/**
permissions/**
+1 -2
View File
@@ -31,8 +31,7 @@ concurrency:
cancel-in-progress: true
jobs:
sdk-analyze:
if: github.repository == 'prowler-cloud/prowler'
analyze:
name: CodeQL Security Analysis
runs-on: ubuntu-latest
timeout-minutes: 30
+11 -5
View File
@@ -5,10 +5,20 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'tests/**'
- 'Dockerfile'
- '.github/workflows/sdk-container-checks.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'tests/**'
- 'Dockerfile'
- '.github/workflows/sdk-container-checks.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -19,7 +29,6 @@ env:
jobs:
sdk-dockerfile-lint:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -43,7 +52,6 @@ jobs:
ignore: DL3013
sdk-container-build-and-scan:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
@@ -59,9 +67,7 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: ./**
files_ignore: |
.github/**
prowler/CHANGELOG.md
docs/**
permissions/**
@@ -94,7 +100,7 @@ jobs:
cache-to: type=gha,mode=max
- name: Scan SDK container with Trivy
if: steps.check-changes.outputs.any_changed == 'true'
if: github.repository == 'prowler-cloud/prowler' && steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/trivy-scan
with:
image-name: ${{ env.IMAGE_NAME }}
+12 -3
View File
@@ -5,10 +5,22 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'tests/**'
- 'pyproject.toml'
- 'poetry.lock'
- '.github/workflows/sdk-security.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'tests/**'
- 'pyproject.toml'
- 'poetry.lock'
- '.github/workflows/sdk-security.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -16,7 +28,6 @@ concurrency:
jobs:
sdk-security-scans:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -30,9 +41,7 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: ./**
files_ignore: |
.github/**
prowler/CHANGELOG.md
docs/**
permissions/**
+12 -3
View File
@@ -5,10 +5,22 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'tests/**'
- 'pyproject.toml'
- 'poetry.lock'
- '.github/workflows/sdk-tests.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'tests/**'
- 'pyproject.toml'
- 'poetry.lock'
- '.github/workflows/sdk-tests.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -16,7 +28,6 @@ concurrency:
jobs:
sdk-tests:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
@@ -37,9 +48,7 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: ./**
files_ignore: |
.github/**
prowler/CHANGELOG.md
docs/**
permissions/**
+1 -2
View File
@@ -27,8 +27,7 @@ concurrency:
cancel-in-progress: true
jobs:
ui-analyze:
if: github.repository == 'prowler-cloud/prowler'
analyze:
name: CodeQL Security Analysis
runs-on: ubuntu-latest
timeout-minutes: 30
+6 -1
View File
@@ -5,10 +5,16 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'ui/**'
- '.github/workflows/ui-container-checks.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'ui/**'
- '.github/workflows/ui-container-checks.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -58,7 +64,6 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: ui/**
files_ignore: |
ui/CHANGELOG.md
ui/README.md
-9
View File
@@ -24,15 +24,6 @@ jobs:
E2E_AWS_PROVIDER_ACCESS_KEY: ${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}
E2E_AWS_PROVIDER_SECRET_KEY: ${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}
E2E_AWS_PROVIDER_ROLE_ARN: ${{ secrets.E2E_AWS_PROVIDER_ROLE_ARN }}
E2E_AZURE_SUBSCRIPTION_ID: ${{ secrets.E2E_AZURE_SUBSCRIPTION_ID }}
E2E_AZURE_CLIENT_ID: ${{ secrets.E2E_AZURE_CLIENT_ID }}
E2E_AZURE_SECRET_ID: ${{ secrets.E2E_AZURE_SECRET_ID }}
E2E_AZURE_TENANT_ID: ${{ secrets.E2E_AZURE_TENANT_ID }}
E2E_M365_DOMAIN_ID: ${{ secrets.E2E_M365_DOMAIN_ID }}
E2E_M365_CLIENT_ID: ${{ secrets.E2E_M365_CLIENT_ID }}
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
E2E_NEW_PASSWORD: ${{ secrets.E2E_NEW_PASSWORD }}
steps:
- name: Checkout repository
+6 -3
View File
@@ -5,10 +5,16 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'ui/**'
- '.github/workflows/ui-tests.yml'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'ui/**'
- '.github/workflows/ui-tests.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -36,9 +42,6 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
with:
files: |
ui/**
.github/workflows/ui-tests.yml
files_ignore: |
ui/CHANGELOG.md
ui/README.md
+6
View File
@@ -39,6 +39,12 @@ secrets-*/
# JUnit Reports
junit-reports/
# Test and coverage artifacts
*_coverage.xml
pytest_*.xml
.coverage
htmlcov/
# VSCode files
.vscode/
+1 -1
View File
@@ -10,4 +10,4 @@
Want some swag as appreciation for your contribution?
# Prowler Developer Guide
https://docs.prowler.com/projects/prowler-open-source/en/latest/developer-guide/introduction/
https://goto.prowler.com/devguide
+1 -1
View File
@@ -88,7 +88,7 @@ prowler dashboard
| Kubernetes | 83 | 7 | 5 | 7 | Official | Stable | UI, API, CLI |
| GitHub | 17 | 2 | 1 | 0 | Official | Stable | UI, API, CLI |
| M365 | 70 | 7 | 3 | 2 | Official | Stable | UI, API, CLI |
| OCI | 51 | 13 | 1 | 10 | Official | Stable | CLI |
| OCI | 51 | 13 | 1 | 10 | Official | Stable | UI, API, CLI |
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | Beta | CLI |
| MongoDB Atlas | 10 | 3 | 0 | 0 | Official | Beta | CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | Beta | CLI |
+10 -3
View File
@@ -2,15 +2,22 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.15.0] (Prowler UNRELEASED)
### Added
- Extend `GET /api/v1/providers` with provider-type filters and optional pagination disable to support the new Overview filters [(#8975)](https://github.com/prowler-cloud/prowler/pull/8975)
- New endpoint to retrieve the number of providers grouped by provider type [(#8975)](https://github.com/prowler-cloud/prowler/pull/8975)
- 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 C5 compliance framework for the GCP provider [(#9097)](https://github.com/prowler-cloud/prowler/pull/9097)
## [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
+4 -59
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -1164,18 +1164,6 @@ files = [
{file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"},
]
[[package]]
name = "circuitbreaker"
version = "2.1.3"
description = "Python Circuit Breaker pattern implementation"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1"},
{file = "circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084"},
]
[[package]]
name = "click"
version = "8.2.1"
@@ -4058,29 +4046,6 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "oci"
version = "2.160.3"
description = "Oracle Cloud Infrastructure Python SDK"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "oci-2.160.3-py3-none-any.whl", hash = "sha256:858bff3e697098bdda44833d2476bfb4632126f0182178e7dbde4dbd156d71f0"},
{file = "oci-2.160.3.tar.gz", hash = "sha256:57514889be3b713a8385d86e3ba8a33cf46e3563c2a7e29a93027fb30b8a2537"},
]
[package.dependencies]
certifi = "*"
circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""}
cryptography = ">=3.2.1,<46.0.0"
pyOpenSSL = ">=17.5.0,<25.0.0"
python-dateutil = ">=2.5.3,<3.0.0"
pytz = ">=2016.10"
[package.extras]
adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""]
[[package]]
name = "openai"
version = "1.101.0"
@@ -4669,7 +4634,6 @@ markdown = "3.9.0"
microsoft-kiota-abstractions = "1.9.2"
msgraph-sdk = "1.23.0"
numpy = "2.0.2"
oci = "2.160.3"
pandas = "2.2.3"
py-iam-expand = "0.1.0"
py-ocsf-models = "0.5.0"
@@ -4686,8 +4650,8 @@ tzlocal = "5.3.1"
[package.source]
type = "git"
url = "https://github.com/prowler-cloud/prowler.git"
reference = "v5.13"
resolved_reference = "b1856e42f0143a64e8cc26c7aa3c7643bd1083d3"
reference = "master"
resolved_reference = "a52697bfdfee83d14a49c11dcbe96888b5cd767e"
[[package]]
name = "psutil"
@@ -5172,25 +5136,6 @@ cffi = ">=1.4.1"
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
[[package]]
name = "pyopenssl"
version = "24.3.0"
description = "Python wrapper module around the OpenSSL library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a"},
{file = "pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36"},
]
[package.dependencies]
cryptography = ">=41.0.5,<45"
[package.extras]
docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"]
test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"]
[[package]]
name = "pyparsing"
version = "3.2.3"
@@ -6841,4 +6786,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.13"
content-hash = "8fcb616e55530e7940019d3da33e955b026b9105e1216a3c5f39b411c015b6d7"
content-hash = "3c9164d668d37d6373eb5200bbe768232ead934d9312b9c68046b1df922789f3"
+2 -2
View File
@@ -24,7 +24,7 @@ dependencies = [
"drf-spectacular-jsonapi==0.5.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.13",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
@@ -43,7 +43,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.14.1"
version = "1.15.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
-3
View File
@@ -1,3 +0,0 @@
# from django.contrib import admin
# Register your models here.
+25 -3
View File
@@ -9,6 +9,25 @@ PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE = {}
PROWLER_CHECKS = {}
AVAILABLE_COMPLIANCE_FRAMEWORKS = {}
# Map API provider names to Prowler directory names
# This is needed because the OCI provider directory is 'oraclecloud' but the provider type is 'oci'
PROVIDER_NAME_MAPPING = {
"oci": "oraclecloud",
}
def get_prowler_provider_name(provider_type: str) -> str:
"""
Map API provider type to Prowler provider directory name.
Args:
provider_type: The provider type from the API (e.g., 'oci', 'aws', 'azure')
Returns:
The provider name used in Prowler's directory structure (e.g., 'oraclecloud', 'aws', 'azure')
"""
return PROVIDER_NAME_MAPPING.get(provider_type, provider_type)
def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]:
"""
@@ -28,8 +47,9 @@ def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[s
"""
global AVAILABLE_COMPLIANCE_FRAMEWORKS
if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS:
prowler_provider_name = get_prowler_provider_name(provider_type)
AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = (
get_available_compliance_frameworks(provider_type)
get_available_compliance_frameworks(prowler_provider_name)
)
return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type]
@@ -49,7 +69,8 @@ def get_prowler_provider_checks(provider_type: Provider.ProviderChoices):
Returns:
Iterable[str]: An iterable of check IDs associated with the specified provider type.
"""
return CheckMetadata.get_bulk(provider_type).keys()
prowler_provider_name = get_prowler_provider_name(provider_type)
return CheckMetadata.get_bulk(prowler_provider_name).keys()
def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) -> dict:
@@ -67,7 +88,8 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) ->
dict: A dictionary mapping compliance framework names to their respective
Compliance objects for the specified provider.
"""
return Compliance.get_bulk(provider_type)
prowler_provider_name = get_prowler_provider_name(provider_type)
return Compliance.get_bulk(prowler_provider_name)
def load_prowler_compliance():
+19 -66
View File
@@ -1,35 +1,18 @@
import re
import secrets
import time
import uuid
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from celery.utils.log import get_task_logger
from config.env import env
from django.conf import settings
from django.contrib.auth.models import BaseUserManager
from django.db import (
DEFAULT_DB_ALIAS,
OperationalError,
connection,
connections,
models,
transaction,
)
from django.db import DEFAULT_DB_ALIAS, connection, connections, models, transaction
from django_celery_beat.models import PeriodicTask
from psycopg2 import connect as psycopg2_connect
from psycopg2.extensions import AsIs, new_type, register_adapter, register_type
from rest_framework_json_api.serializers import ValidationError
from api.db_router import (
READ_REPLICA_ALIAS,
get_read_db_alias,
reset_read_db_alias,
set_read_db_alias,
)
logger = get_task_logger(__name__)
from api.db_router import get_read_db_alias, reset_read_db_alias, set_read_db_alias
DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test"
DB_PASSWORD = (
@@ -45,9 +28,6 @@ TASK_RUNNER_DB_TABLE = "django_celery_results_taskresult"
POSTGRES_TENANT_VAR = "api.tenant_id"
POSTGRES_USER_VAR = "api.user_id"
REPLICA_MAX_ATTEMPTS = env.int("POSTGRES_REPLICA_MAX_ATTEMPTS", default=3)
REPLICA_RETRY_BASE_DELAY = env.float("POSTGRES_REPLICA_RETRY_BASE_DELAY", default=0.5)
SET_CONFIG_QUERY = "SELECT set_config(%s, %s::text, TRUE);"
@@ -91,51 +71,24 @@ def rls_transaction(
if db_alias not in connections:
db_alias = DEFAULT_DB_ALIAS
alias = db_alias
is_replica = READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS
max_attempts = REPLICA_MAX_ATTEMPTS if is_replica else 1
router_token = None
try:
if db_alias != DEFAULT_DB_ALIAS:
router_token = set_read_db_alias(db_alias)
for attempt in range(1, max_attempts + 1):
router_token = None
# On final attempt, fallback to primary
if attempt == max_attempts and is_replica:
logger.warning(
f"RLS transaction failed after {attempt - 1} attempts on replica, "
f"falling back to primary DB"
)
alias = DEFAULT_DB_ALIAS
conn = connections[alias]
try:
if alias != DEFAULT_DB_ALIAS:
router_token = set_read_db_alias(alias)
with transaction.atomic(using=alias):
with conn.cursor() as cursor:
try:
# just in case the value is a UUID object
uuid.UUID(str(value))
except ValueError:
raise ValidationError("Must be a valid UUID")
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
yield cursor
return
except OperationalError as e:
# If on primary or max attempts reached, raise
if not is_replica or attempt == max_attempts:
raise
# Retry with exponential backoff
delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
logger.info(
f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), "
f"retrying in {delay}s. Error: {e}"
)
time.sleep(delay)
finally:
if router_token is not None:
reset_read_db_alias(router_token)
with transaction.atomic(using=db_alias):
conn = connections[db_alias]
with conn.cursor() as cursor:
try:
# just in case the value is a UUID object
uuid.UUID(str(value))
except ValueError:
raise ValidationError("Must be a valid UUID")
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
yield cursor
finally:
if router_token is not None:
reset_read_db_alias(router_token)
class CustomUserManager(BaseUserManager):
+52
View File
@@ -27,6 +27,8 @@ from api.models import (
Finding,
Integration,
Invitation,
LighthouseProviderConfiguration,
LighthouseProviderModels,
Membership,
OverviewStatusChoices,
PermissionChoices,
@@ -245,6 +247,14 @@ class ProviderFilter(FilterSet):
choices=Provider.ProviderChoices.choices,
lookup_expr="in",
)
provider_type = ChoiceFilter(
choices=Provider.ProviderChoices.choices, field_name="provider"
)
provider_type__in = ChoiceInFilter(
field_name="provider",
choices=Provider.ProviderChoices.choices,
lookup_expr="in",
)
class Meta:
model = Provider
@@ -928,3 +938,45 @@ class TenantApiKeyFilter(FilterSet):
"revoked": ["exact"],
"name": ["exact", "icontains"],
}
class LighthouseProviderConfigFilter(FilterSet):
provider_type = ChoiceFilter(
choices=LighthouseProviderConfiguration.LLMProviderChoices.choices
)
provider_type__in = ChoiceInFilter(
choices=LighthouseProviderConfiguration.LLMProviderChoices.choices,
field_name="provider_type",
lookup_expr="in",
)
is_active = BooleanFilter()
class Meta:
model = LighthouseProviderConfiguration
fields = {
"provider_type": ["exact", "in"],
"is_active": ["exact"],
}
class LighthouseProviderModelsFilter(FilterSet):
provider_type = ChoiceFilter(
choices=LighthouseProviderConfiguration.LLMProviderChoices.choices,
field_name="provider_configuration__provider_type",
)
provider_type__in = ChoiceInFilter(
choices=LighthouseProviderConfiguration.LLMProviderChoices.choices,
field_name="provider_configuration__provider_type",
lookup_expr="in",
)
# Allow filtering by model id
model_id = CharFilter(field_name="model_id", lookup_expr="exact")
model_id__icontains = CharFilter(field_name="model_id", lookup_expr="icontains")
model_id__in = CharInFilter(field_name="model_id", lookup_expr="in")
class Meta:
model = LighthouseProviderModels
fields = {
"model_id": ["exact", "icontains", "in"],
}
@@ -0,0 +1,266 @@
# Generated by Django 5.1.12 on 2025-10-09 07:50
import json
import logging
import uuid
import django.db.models.deletion
from config.custom_logging import BackendLogger
from cryptography.fernet import Fernet
from django.conf import settings
from django.db import migrations, models
import api.rls
from api.db_router import MainRouter
logger = logging.getLogger(BackendLogger.API)
def migrate_lighthouse_configs_forward(apps, schema_editor):
"""
Migrate data from old LighthouseConfiguration to new multi-provider models.
Old system: one LighthouseConfiguration per tenant (always OpenAI).
"""
LighthouseConfiguration = apps.get_model("api", "LighthouseConfiguration")
LighthouseProviderConfiguration = apps.get_model(
"api", "LighthouseProviderConfiguration"
)
LighthouseTenantConfiguration = apps.get_model(
"api", "LighthouseTenantConfiguration"
)
LighthouseProviderModels = apps.get_model("api", "LighthouseProviderModels")
fernet = Fernet(settings.SECRETS_ENCRYPTION_KEY.encode())
# Migrate only tenants that actually have a LighthouseConfiguration
for old_config in (
LighthouseConfiguration.objects.using(MainRouter.admin_db)
.select_related("tenant")
.all()
):
tenant = old_config.tenant
tenant_id = str(tenant.id)
try:
# Create OpenAI provider configuration for this tenant
api_key_decrypted = fernet.decrypt(bytes(old_config.api_key)).decode()
credentials_encrypted = fernet.encrypt(
json.dumps({"api_key": api_key_decrypted}).encode()
)
provider_config = LighthouseProviderConfiguration.objects.using(
MainRouter.admin_db
).create(
tenant=tenant,
provider_type="openai",
credentials=credentials_encrypted,
is_active=old_config.is_active,
)
# Create tenant configuration from old values
LighthouseTenantConfiguration.objects.using(MainRouter.admin_db).create(
tenant=tenant,
business_context=old_config.business_context or "",
default_provider="openai",
default_models={"openai": old_config.model},
)
# Create initial provider model record
LighthouseProviderModels.objects.using(MainRouter.admin_db).create(
tenant=tenant,
provider_configuration=provider_config,
model_id=old_config.model,
model_name=old_config.model,
default_parameters={},
)
except Exception:
logger.exception(
"Failed to migrate lighthouse config for tenant %s", tenant_id
)
continue
class Migration(migrations.Migration):
dependencies = [
("api", "0049_compliancerequirementoverview_passed_failed_findings"),
]
operations = [
migrations.CreateModel(
name="LighthouseProviderConfiguration",
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)),
(
"provider_type",
models.CharField(
choices=[("openai", "OpenAI")],
help_text="LLM provider name",
max_length=50,
),
),
("base_url", models.URLField(blank=True, null=True)),
(
"credentials",
models.BinaryField(
help_text="Encrypted JSON credentials for the provider"
),
),
("is_active", models.BooleanField(default=True)),
],
options={
"db_table": "lighthouse_provider_configurations",
"abstract": False,
},
),
migrations.CreateModel(
name="LighthouseProviderModels",
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)),
("model_id", models.CharField(max_length=100)),
("model_name", models.CharField(max_length=100)),
("default_parameters", models.JSONField(blank=True, default=dict)),
],
options={
"db_table": "lighthouse_provider_models",
"abstract": False,
},
),
migrations.CreateModel(
name="LighthouseTenantConfiguration",
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)),
("business_context", models.TextField(blank=True, default="")),
("default_provider", models.CharField(blank=True, max_length=50)),
("default_models", models.JSONField(blank=True, default=dict)),
],
options={
"db_table": "lighthouse_tenant_config",
"abstract": False,
},
),
migrations.AddField(
model_name="lighthouseproviderconfiguration",
name="tenant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
migrations.AddField(
model_name="lighthouseprovidermodels",
name="provider_configuration",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="available_models",
to="api.lighthouseproviderconfiguration",
),
),
migrations.AddField(
model_name="lighthouseprovidermodels",
name="tenant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
migrations.AddField(
model_name="lighthousetenantconfiguration",
name="tenant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
migrations.AddIndex(
model_name="lighthouseproviderconfiguration",
index=models.Index(
fields=["tenant_id", "provider_type"], name="lh_pc_tenant_type_idx"
),
),
migrations.AddConstraint(
model_name="lighthouseproviderconfiguration",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_lighthouseproviderconfiguration",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="lighthouseproviderconfiguration",
constraint=models.UniqueConstraint(
fields=("tenant_id", "provider_type"),
name="unique_provider_config_per_tenant",
),
),
migrations.AddIndex(
model_name="lighthouseprovidermodels",
index=models.Index(
fields=["tenant_id", "provider_configuration"],
name="lh_prov_models_cfg_idx",
),
),
migrations.AddConstraint(
model_name="lighthouseprovidermodels",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_lighthouseprovidermodels",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="lighthouseprovidermodels",
constraint=models.UniqueConstraint(
fields=("tenant_id", "provider_configuration", "model_id"),
name="unique_provider_model_per_configuration",
),
),
migrations.AddConstraint(
model_name="lighthousetenantconfiguration",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_lighthousetenantconfiguration",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="lighthousetenantconfiguration",
constraint=models.UniqueConstraint(
fields=("tenant_id",), name="unique_tenant_lighthouse_config"
),
),
# Migrate data from old LighthouseConfiguration to new tables
# This runs after all tables, indexes, and constraints are created
# The old Lighthouse configuration table is not removed, so reverse_code is noop
# During rollbacks, the old Lighthouse configuration remains intact while the new tables are removed
migrations.RunPython(
migrate_lighthouse_configs_forward,
reverse_code=migrations.RunPython.noop,
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.1.7 on 2025-10-14 00:00
from django.db import migrations
import api.db_utils
class Migration(migrations.Migration):
dependencies = [
("api", "0050_lighthouse_multi_llm"),
]
operations = [
migrations.AlterField(
model_name="provider",
name="provider",
field=api.db_utils.ProviderEnumField(
choices=[
("aws", "AWS"),
("azure", "Azure"),
("gcp", "GCP"),
("kubernetes", "Kubernetes"),
("m365", "M365"),
("github", "GitHub"),
("oci", "Oracle Cloud Infrastructure"),
],
default="aws",
),
),
migrations.RunSQL(
"ALTER TYPE provider ADD VALUE IF NOT EXISTS 'oci';",
reverse_sql=migrations.RunSQL.noop,
),
]
+197 -25
View File
@@ -284,6 +284,7 @@ class Provider(RowLevelSecurityProtectedModel):
KUBERNETES = "kubernetes", _("Kubernetes")
M365 = "m365", _("M365")
GITHUB = "github", _("GitHub")
OCI = "oci", _("Oracle Cloud Infrastructure")
@staticmethod
def validate_aws_uid(value):
@@ -354,6 +355,18 @@ class Provider(RowLevelSecurityProtectedModel):
pointer="/data/attributes/uid",
)
@staticmethod
def validate_oci_uid(value):
if not re.match(
r"^ocid1\.([a-z0-9_-]+)\.([a-z0-9_-]+)\.([a-z0-9_-]*)\.([a-z0-9]+)$", value
):
raise ModelValidationError(
detail="Oracle Cloud Infrastructure provider ID must be a valid tenancy OCID in the format: "
"ocid1.<resource_type>.<realm>.<region>.<unique_id>",
code="oci-uid",
pointer="/data/attributes/uid",
)
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)
@@ -1873,22 +1886,6 @@ class LighthouseConfiguration(RowLevelSecurityProtectedModel):
def clean(self):
super().clean()
# Validate temperature
if not 0 <= self.temperature <= 1:
raise ModelValidationError(
detail="Temperature must be between 0 and 1",
code="invalid_temperature",
pointer="/data/attributes/temperature",
)
# Validate max_tokens
if not 500 <= self.max_tokens <= 5000:
raise ModelValidationError(
detail="Max tokens must be between 500 and 5000",
code="invalid_max_tokens",
pointer="/data/attributes/max_tokens",
)
@property
def api_key_decoded(self):
"""Return the decrypted API key, or None if unavailable or invalid."""
@@ -1913,15 +1910,6 @@ class LighthouseConfiguration(RowLevelSecurityProtectedModel):
code="invalid_api_key",
pointer="/data/attributes/api_key",
)
# Validate OpenAI API key format
openai_key_pattern = r"^sk-[\w-]+T3BlbkFJ[\w-]+$"
if not re.match(openai_key_pattern, value):
raise ModelValidationError(
detail="Invalid OpenAI API key format.",
code="invalid_api_key",
pointer="/data/attributes/api_key",
)
self.api_key = fernet.encrypt(value.encode())
def save(self, *args, **kwargs):
@@ -1984,3 +1972,187 @@ class Processor(RowLevelSecurityProtectedModel):
class JSONAPIMeta:
resource_name = "processors"
class LighthouseProviderConfiguration(RowLevelSecurityProtectedModel):
"""
Per-tenant configuration for an LLM provider (credentials, base URL, activation).
One configuration per provider type per tenant.
"""
class LLMProviderChoices(models.TextChoices):
OPENAI = "openai", _("OpenAI")
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)
provider_type = models.CharField(
max_length=50,
choices=LLMProviderChoices.choices,
help_text="LLM provider name",
)
# For OpenAI-compatible providers
base_url = models.URLField(blank=True, null=True)
# Encrypted JSON for provider-specific auth
credentials = models.BinaryField(
blank=False, null=False, help_text="Encrypted JSON credentials for the provider"
)
is_active = models.BooleanField(default=True)
def __str__(self):
return f"{self.get_provider_type_display()} ({self.tenant_id})"
def clean(self):
super().clean()
@property
def credentials_decoded(self):
if not self.credentials:
return None
try:
decrypted_data = fernet.decrypt(bytes(self.credentials))
return json.loads(decrypted_data.decode())
except (InvalidToken, json.JSONDecodeError) as e:
logger.warning("Failed to decrypt provider credentials: %s", e)
return None
except Exception as e:
logger.exception(
"Unexpected error while decrypting provider credentials: %s", e
)
return None
@credentials_decoded.setter
def credentials_decoded(self, value):
"""
Set and encrypt credentials (assumes serializer performed validation).
"""
if not value:
raise ModelValidationError(
detail="Credentials are required",
code="invalid_credentials",
pointer="/data/attributes/credentials",
)
self.credentials = fernet.encrypt(json.dumps(value).encode())
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "lighthouse_provider_configurations"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
models.UniqueConstraint(
fields=["tenant_id", "provider_type"],
name="unique_provider_config_per_tenant",
),
]
indexes = [
models.Index(
fields=["tenant_id", "provider_type"],
name="lh_pc_tenant_type_idx",
),
]
class JSONAPIMeta:
resource_name = "lighthouse-providers"
class LighthouseTenantConfiguration(RowLevelSecurityProtectedModel):
"""
Tenant-level Lighthouse settings (business context and defaults).
One record per tenant.
"""
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)
business_context = models.TextField(blank=True, default="")
# Preferred provider key (e.g., "openai", "bedrock", "openai_compatible")
default_provider = models.CharField(max_length=50, blank=True)
# Mapping of provider -> model id, e.g., {"openai": "gpt-4o", "bedrock": "anthropic.claude-v2"}
default_models = models.JSONField(default=dict, blank=True)
def __str__(self):
return f"Lighthouse Tenant Config for {self.tenant_id}"
def clean(self):
super().clean()
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "lighthouse_tenant_config"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
models.UniqueConstraint(
fields=["tenant_id"], name="unique_tenant_lighthouse_config"
),
]
class JSONAPIMeta:
resource_name = "lighthouse-configurations"
class LighthouseProviderModels(RowLevelSecurityProtectedModel):
"""
Per-tenant, per-provider configuration list of available LLM models.
RLS-protected; populated via provider API using tenant-scoped credentials.
"""
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)
# Scope to a specific provider configuration within a tenant
provider_configuration = models.ForeignKey(
LighthouseProviderConfiguration,
on_delete=models.CASCADE,
related_name="available_models",
)
model_id = models.CharField(max_length=100)
# Human-friendly model name
model_name = models.CharField(max_length=100)
# Model-specific default parameters (e.g., temperature, max_tokens)
default_parameters = models.JSONField(default=dict, blank=True)
def __str__(self):
return f"{self.provider_configuration.provider_type}:{self.model_id} ({self.tenant_id})"
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "lighthouse_provider_models"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
models.UniqueConstraint(
fields=["tenant_id", "provider_configuration", "model_id"],
name="unique_provider_model_per_configuration",
),
]
indexes = [
models.Index(
fields=["tenant_id", "provider_configuration"],
name="lh_prov_models_cfg_idx",
),
]
class JSONAPIMeta:
resource_name = "lighthouse-models"
+38 -1
View File
@@ -6,7 +6,14 @@ from django.dispatch import receiver
from django_celery_results.backends.database import DatabaseBackend
from api.db_utils import delete_related_daily_task
from api.models import Membership, Provider, TenantAPIKey, User
from api.models import (
LighthouseProviderConfiguration,
LighthouseTenantConfiguration,
Membership,
Provider,
TenantAPIKey,
User,
)
def create_task_result_on_publish(sender=None, headers=None, **kwargs): # noqa: F841
@@ -56,3 +63,33 @@ def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841
TenantAPIKey.objects.filter(
entity=instance.user, tenant_id=instance.tenant.id
).update(revoked=True)
@receiver(pre_delete, sender=LighthouseProviderConfiguration)
def cleanup_lighthouse_defaults_before_delete(sender, instance, **kwargs): # noqa: F841
"""
Ensure tenant Lighthouse defaults do not reference a soon-to-be-deleted provider.
This runs for both per-instance deletes and queryset (bulk) deletes.
"""
try:
tenant_cfg = LighthouseTenantConfiguration.objects.get(
tenant_id=instance.tenant_id
)
except LighthouseTenantConfiguration.DoesNotExist:
return
updated = False
defaults = tenant_cfg.default_models or {}
if instance.provider_type in defaults:
defaults.pop(instance.provider_type, None)
tenant_cfg.default_models = defaults
updated = True
if tenant_cfg.default_provider == instance.provider_type:
tenant_cfg.default_provider = ""
updated = True
if updated:
tenant_cfg.save()
File diff suppressed because it is too large Load Diff
@@ -1,39 +0,0 @@
"""Tests for rls_transaction retry and fallback logic."""
import pytest
from django.db import DEFAULT_DB_ALIAS
from rest_framework_json_api.serializers import ValidationError
from api.db_utils import rls_transaction
@pytest.mark.django_db
class TestRLSTransaction:
"""Simple integration tests for rls_transaction using real DB."""
@pytest.fixture
def tenant(self, tenants_fixture):
return tenants_fixture[0]
def test_success_on_primary(self, tenant):
"""Basic: transaction succeeds on primary database."""
with rls_transaction(str(tenant.id), using=DEFAULT_DB_ALIAS) as cursor:
cursor.execute("SELECT 1")
result = cursor.fetchone()
assert result == (1,)
def test_invalid_uuid_raises_validation_error(self):
"""Invalid UUID raises ValidationError before DB operations."""
with pytest.raises(ValidationError, match="Must be a valid UUID"):
with rls_transaction("not-a-uuid", using=DEFAULT_DB_ALIAS):
pass
def test_custom_parameter_name(self, tenant):
"""Test custom RLS parameter name."""
custom_param = "api.custom_id"
with rls_transaction(
str(tenant.id), parameter=custom_param, using=DEFAULT_DB_ALIAS
) as cursor:
cursor.execute("SELECT current_setting(%s, true)", [custom_param])
result = cursor.fetchone()
assert result == (str(tenant.id),)
+1 -510
View File
@@ -1,15 +1,12 @@
from datetime import datetime, timezone
from enum import Enum
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from django.conf import settings
from django.db import DEFAULT_DB_ALIAS, OperationalError
from freezegun import freeze_time
from rest_framework_json_api.serializers import ValidationError
from api.db_utils import (
POSTGRES_TENANT_VAR,
_should_create_index_on_partition,
batch_delete,
create_objects_in_batches,
@@ -17,22 +14,11 @@ from api.db_utils import (
generate_api_key_prefix,
generate_random_token,
one_week_from_now,
rls_transaction,
update_objects_in_batches,
)
from api.models import Provider
@pytest.fixture
def enable_read_replica():
"""
Fixture to enable READ_REPLICA_ALIAS for tests that need replica functionality.
This avoids polluting the global test configuration.
"""
with patch("api.db_utils.READ_REPLICA_ALIAS", "replica"):
yield "replica"
class TestEnumToChoices:
def test_enum_to_choices_simple(self):
class Color(Enum):
@@ -353,498 +339,3 @@ class TestGenerateApiKeyPrefix:
prefix = generate_api_key_prefix()
random_part = prefix[3:] # Strip 'pk_'
assert all(char in allowed_chars for char in random_part)
@pytest.mark.django_db
class TestRlsTransaction:
def test_rls_transaction_valid_uuid_string(self, tenants_fixture):
"""Test rls_transaction with valid UUID string."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with rls_transaction(tenant_id) as cursor:
assert cursor is not None
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == tenant_id
def test_rls_transaction_valid_uuid_object(self, tenants_fixture):
"""Test rls_transaction with UUID object."""
tenant = tenants_fixture[0]
with rls_transaction(tenant.id) as cursor:
assert cursor is not None
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == str(tenant.id)
def test_rls_transaction_invalid_uuid_raises_validation_error(self):
"""Test rls_transaction raises ValidationError for invalid UUID."""
invalid_uuid = "not-a-valid-uuid"
with pytest.raises(ValidationError, match="Must be a valid UUID"):
with rls_transaction(invalid_uuid):
pass
def test_rls_transaction_uses_default_database_when_no_alias(self, tenants_fixture):
"""Test rls_transaction uses DEFAULT_DB_ALIAS when no alias specified."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with rls_transaction(tenant_id):
pass
mock_connections.__getitem__.assert_called_with(DEFAULT_DB_ALIAS)
def test_rls_transaction_uses_specified_alias(self, tenants_fixture):
"""Test rls_transaction uses specified database alias via using parameter."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
custom_alias = "custom_db"
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias:
mock_set_alias.return_value = "test_token"
with rls_transaction(tenant_id, using=custom_alias):
pass
mock_connections.__getitem__.assert_called_with(custom_alias)
mock_set_alias.assert_called_once_with(custom_alias)
mock_reset_alias.assert_called_once_with("test_token")
def test_rls_transaction_uses_read_replica_from_router(
self, tenants_fixture, enable_read_replica
):
"""Test rls_transaction uses read replica alias from router."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch(
"api.db_utils.reset_read_db_alias"
) as mock_reset_alias:
mock_set_alias.return_value = "test_token"
with rls_transaction(tenant_id):
pass
mock_connections.__getitem__.assert_called()
mock_set_alias.assert_called_once()
mock_reset_alias.assert_called_once()
def test_rls_transaction_fallback_to_default_when_alias_not_in_connections(
self, tenants_fixture
):
"""Test rls_transaction falls back to DEFAULT_DB_ALIAS when alias not in connections."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
invalid_alias = "nonexistent_db"
with patch("api.db_utils.get_read_db_alias", return_value=invalid_alias):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
def contains_check(alias):
return alias == DEFAULT_DB_ALIAS
mock_connections.__contains__.side_effect = contains_check
mock_connections.__getitem__.return_value = mock_conn
with patch("api.db_utils.transaction.atomic"):
with rls_transaction(tenant_id):
pass
mock_connections.__getitem__.assert_called_with(DEFAULT_DB_ALIAS)
def test_rls_transaction_successful_execution_on_replica_no_retries(
self, tenants_fixture, enable_read_replica
):
"""Test successful execution on replica without retries."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias", return_value="token"):
with patch("api.db_utils.reset_read_db_alias"):
with rls_transaction(tenant_id):
pass
assert mock_cursor.execute.call_count == 1
def test_rls_transaction_retry_with_exponential_backoff_on_operational_error(
self, tenants_fixture, enable_read_replica
):
"""Test retry with exponential backoff on OperationalError on replica."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
call_count = 0
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise OperationalError("Connection error")
return MagicMock(
__enter__=MagicMock(return_value=None),
__exit__=MagicMock(return_value=False),
)
with patch(
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
):
with patch("api.db_utils.time.sleep") as mock_sleep:
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with patch("api.db_utils.logger") as mock_logger:
with rls_transaction(tenant_id):
pass
assert mock_sleep.call_count == 2
mock_sleep.assert_any_call(0.5)
mock_sleep.assert_any_call(1.0)
assert mock_logger.info.call_count == 2
def test_rls_transaction_max_three_attempts_for_replica(
self, tenants_fixture, enable_read_replica
):
"""Test maximum 3 attempts for replica database."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = OperationalError("Persistent error")
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(OperationalError):
with rls_transaction(tenant_id):
pass
assert mock_atomic.call_count == 3
def test_rls_transaction_only_one_attempt_for_primary(self, tenants_fixture):
"""Test only 1 attempt for primary database."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = OperationalError("Primary error")
with pytest.raises(OperationalError):
with rls_transaction(tenant_id):
pass
assert mock_atomic.call_count == 1
def test_rls_transaction_fallback_to_primary_after_max_attempts(
self, tenants_fixture, enable_read_replica
):
"""Test fallback to primary DB after max attempts on replica."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
call_count = 0
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise OperationalError("Replica error")
return MagicMock(
__enter__=MagicMock(return_value=None),
__exit__=MagicMock(return_value=False),
)
with patch(
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
):
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with patch("api.db_utils.logger") as mock_logger:
with rls_transaction(tenant_id):
pass
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
assert "falling back to primary DB" in warning_msg
def test_rls_transaction_logger_warning_on_fallback(
self, tenants_fixture, enable_read_replica
):
"""Test logger warnings are emitted on fallback to primary."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
call_count = 0
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise OperationalError("Replica error")
return MagicMock(
__enter__=MagicMock(return_value=None),
__exit__=MagicMock(return_value=False),
)
with patch(
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
):
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with patch("api.db_utils.logger") as mock_logger:
with rls_transaction(tenant_id):
pass
assert mock_logger.info.call_count == 2
assert mock_logger.warning.call_count == 1
def test_rls_transaction_operational_error_raised_immediately_on_primary(
self, tenants_fixture
):
"""Test OperationalError raised immediately on primary without retry."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = OperationalError("Primary error")
with patch("api.db_utils.time.sleep") as mock_sleep:
with pytest.raises(OperationalError):
with rls_transaction(tenant_id):
pass
mock_sleep.assert_not_called()
def test_rls_transaction_operational_error_raised_after_max_attempts(
self, tenants_fixture, enable_read_replica
):
"""Test OperationalError raised after max attempts on replica."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = OperationalError(
"Persistent replica error"
)
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(OperationalError):
with rls_transaction(tenant_id):
pass
def test_rls_transaction_router_token_set_for_non_default_alias(
self, tenants_fixture
):
"""Test router token is set when using non-default alias."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
custom_alias = "custom_db"
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias:
mock_set_alias.return_value = "test_token"
with rls_transaction(tenant_id, using=custom_alias):
pass
mock_set_alias.assert_called_once_with(custom_alias)
mock_reset_alias.assert_called_once_with("test_token")
def test_rls_transaction_router_token_reset_in_finally_block(self, tenants_fixture):
"""Test router token is reset in finally block even on error."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
custom_alias = "custom_db"
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = Exception("Unexpected error")
with patch("api.db_utils.set_read_db_alias", return_value="test_token"):
with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias:
with pytest.raises(Exception):
with rls_transaction(tenant_id, using=custom_alias):
pass
mock_reset_alias.assert_called_once_with("test_token")
def test_rls_transaction_router_token_not_set_for_default_alias(
self, tenants_fixture
):
"""Test router token is not set when using default alias."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch(
"api.db_utils.reset_read_db_alias"
) as mock_reset_alias:
with rls_transaction(tenant_id):
pass
mock_set_alias.assert_not_called()
mock_reset_alias.assert_not_called()
def test_rls_transaction_set_config_query_executed_with_correct_params(
self, tenants_fixture
):
"""Test SET_CONFIG_QUERY executed with correct parameters."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with rls_transaction(tenant_id) as cursor:
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == tenant_id
def test_rls_transaction_custom_parameter(self, tenants_fixture):
"""Test rls_transaction with custom parameter name."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
custom_param = "api.user_id"
with rls_transaction(tenant_id, parameter=custom_param) as cursor:
cursor.execute("SELECT current_setting(%s)", [custom_param])
result = cursor.fetchone()
assert result[0] == tenant_id
def test_rls_transaction_cursor_yielded_correctly(self, tenants_fixture):
"""Test cursor is yielded correctly."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with rls_transaction(tenant_id) as cursor:
assert cursor is not None
cursor.execute("SELECT 1")
result = cursor.fetchone()
assert result[0] == 1
+6
View File
@@ -22,6 +22,7 @@ from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
from prowler.providers.m365.m365_provider import M365Provider
from prowler.providers.oraclecloud.oci_provider import OciProvider
class TestMergeDicts:
@@ -108,6 +109,7 @@ class TestReturnProwlerProvider:
(Provider.ProviderChoices.AZURE.value, AzureProvider),
(Provider.ProviderChoices.KUBERNETES.value, KubernetesProvider),
(Provider.ProviderChoices.M365.value, M365Provider),
(Provider.ProviderChoices.OCI.value, OciProvider),
],
)
def test_return_prowler_provider(self, provider_type, expected_provider):
@@ -203,6 +205,10 @@ class TestGetProwlerProviderKwargs:
Provider.ProviderChoices.GITHUB.value,
{"organizations": ["provider_uid"]},
),
(
Provider.ProviderChoices.OCI.value,
{},
),
],
)
def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs):
+639 -4
View File
@@ -23,6 +23,7 @@ from conftest import (
today_after_n_days,
)
from django.conf import settings
from django.db.models import Count
from django.http import JsonResponse
from django.test import RequestFactory
from django.urls import reverse
@@ -35,6 +36,9 @@ from api.db_router import MainRouter
from api.models import (
Integration,
Invitation,
LighthouseProviderConfiguration,
LighthouseProviderModels,
LighthouseTenantConfiguration,
Membership,
Processor,
Provider,
@@ -944,6 +948,74 @@ class TestProviderViewSet:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(providers_fixture)
def test_providers_filter_provider_type(
self, authenticated_client, providers_fixture
):
response = authenticated_client.get(
reverse("provider-list"), {"filter[provider_type]": "aws"}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 2
assert all(item["attributes"]["provider"] == "aws" for item in data)
def test_providers_filter_provider_type_in(
self, authenticated_client, providers_fixture
):
response = authenticated_client.get(
reverse("provider-list"), {"filter[provider_type__in]": "aws,gcp"}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 3
assert {"aws", "gcp"} >= {item["attributes"]["provider"] for item in data}
def test_providers_filter_provider_type_invalid(
self, authenticated_client, providers_fixture
):
response = authenticated_client.get(
reverse("provider-list"), {"filter[provider_type]": "invalid"}
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_providers_disable_pagination(
self, authenticated_client, providers_fixture, tenants_fixture
):
tenant, *_ = tenants_fixture
existing_count = Provider.objects.filter(tenant_id=tenant.id).count()
target_total = settings.REST_FRAMEWORK["PAGE_SIZE"] + 1
additional_needed = max(0, target_total - existing_count)
base_uid = 200000000000
for index in range(additional_needed):
Provider.objects.create(
tenant_id=tenant.id,
provider=Provider.ProviderChoices.AWS,
uid=f"{base_uid + index:012d}",
alias=f"aws_extra_{index}",
)
total_providers = Provider.objects.filter(tenant_id=tenant.id).count()
paginated_response = authenticated_client.get(reverse("provider-list"))
assert paginated_response.status_code == status.HTTP_200_OK
paginated_data = paginated_response.json()["data"]
assert len(paginated_data) == min(
settings.REST_FRAMEWORK["PAGE_SIZE"], total_providers
)
paginated_meta = paginated_response.json().get("meta", {})
assert "pagination" in paginated_meta
assert paginated_meta["pagination"]["count"] == total_providers
unpaginated_response = authenticated_client.get(
reverse("provider-list"), {"page[disable]": "true"}
)
assert unpaginated_response.status_code == status.HTTP_200_OK
unpaginated_data = unpaginated_response.json()["data"]
assert len(unpaginated_data) == total_providers
unpaginated_meta = unpaginated_response.json().get("meta", {})
assert "pagination" not in unpaginated_meta
@pytest.mark.parametrize(
"include_values, expected_resources",
[
@@ -1387,13 +1459,25 @@ class TestProviderViewSet:
("provider", "aws", 2),
("provider.in", "azure,gcp", 2),
("uid", "123456789012", 1),
("uid.icontains", "1", 5),
(
"uid.icontains",
"1",
6,
), # Updated: includes OCI provider with "1" in UID
("alias", "aws_testing_1", 1),
("alias.icontains", "aws", 2),
("inserted_at", TODAY, 6),
("inserted_at.gte", "2024-01-01", 6),
("inserted_at", TODAY, 7), # Updated: 7 providers now (added OCI)
(
"inserted_at.gte",
"2024-01-01",
7,
), # Updated: 7 providers now (added OCI)
("inserted_at.lte", "2024-01-01", 0),
("updated_at.gte", "2024-01-01", 6),
(
"updated_at.gte",
"2024-01-01",
7,
), # Updated: 7 providers now (added OCI)
("updated_at.lte", "2024-01-01", 0),
]
),
@@ -1896,6 +1980,43 @@ class TestProviderSecretViewSet:
"password": "supersecret",
},
),
# OCI with API key credentials (with key_content)
(
Provider.ProviderChoices.OCI.value,
ProviderSecret.TypeChoices.STATIC,
{
"user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-content\n-----END RSA PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
"region": "us-ashburn-1",
},
),
# OCI with API key credentials (with key_file)
(
Provider.ProviderChoices.OCI.value,
ProviderSecret.TypeChoices.STATIC,
{
"user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_file": "/path/to/oci_api_key.pem",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
"region": "us-ashburn-1",
},
),
# OCI with API key credentials (with passphrase)
(
Provider.ProviderChoices.OCI.value,
ProviderSecret.TypeChoices.STATIC,
{
"user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-encrypted-key\n-----END RSA PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
"region": "us-ashburn-1",
"pass_phrase": "my-secure-passphrase",
},
),
],
)
def test_provider_secrets_create_valid(
@@ -5839,6 +5960,40 @@ class TestOverviewViewSet:
assert attributes["findings"]["muted"] == 2
assert attributes["resources"]["total"] == 4
def test_overview_providers_count(
self,
authenticated_client,
scan_summaries_fixture,
resources_fixture,
providers_fixture,
tenants_fixture,
):
tenant = tenants_fixture[0]
default_response = authenticated_client.get(reverse("overview-providers"))
assert default_response.status_code == status.HTTP_200_OK
default_data = default_response.json()["data"]
assert len(default_data) == 1
assert all("count" not in item["attributes"] for item in default_data)
grouped_response = authenticated_client.get(reverse("overview-providers-count"))
assert grouped_response.status_code == status.HTTP_200_OK
grouped_data = grouped_response.json()["data"]
assert len(grouped_data) >= 1
aggregated = {
entry["id"]: entry["attributes"]["count"] for entry in grouped_data
}
db_counts = (
Provider.objects.filter(tenant_id=tenant.id, is_deleted=False)
.values("provider")
.annotate(count=Count("id"))
)
expected = {row["provider"]: row["count"] for row in db_counts}
assert aggregated == expected
for entry in grouped_data:
assert "findings" not in entry["attributes"]
def test_overview_services_list_no_required_filters(
self, authenticated_client, scan_summaries_fixture
):
@@ -8758,3 +8913,483 @@ class TestTenantApiKeyViewSet:
# Verify error object structure
error = response_data["errors"][0]
assert "detail" in error or "title" in error
@pytest.mark.django_db
class TestLighthouseTenantConfigViewSet:
"""Test Lighthouse tenant configuration endpoint (singleton pattern)"""
def test_lighthouse_tenant_config_create_via_patch(self, authenticated_client):
"""Test creating a tenant config successfully via PATCH (upsert)"""
payload = {
"data": {
"type": "lighthouse-configurations",
"attributes": {
"business_context": "Test business context for security analysis",
"default_provider": "",
"default_models": {},
},
}
}
response = authenticated_client.patch(
reverse("lighthouse-configurations"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert (
data["attributes"]["business_context"]
== "Test business context for security analysis"
)
assert data["attributes"]["default_provider"] == ""
assert data["attributes"]["default_models"] == {}
def test_lighthouse_tenant_config_upsert_behavior(self, authenticated_client):
"""Test that PATCH creates config if not exists and updates if exists (upsert)"""
payload = {
"data": {
"type": "lighthouse-configurations",
"attributes": {
"business_context": "First config",
},
}
}
# First PATCH creates the config
response = authenticated_client.patch(
reverse("lighthouse-configurations"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_200_OK
first_data = response.json()["data"]
assert first_data["attributes"]["business_context"] == "First config"
# Second PATCH updates the same config (not creating a duplicate)
payload["data"]["attributes"]["business_context"] = "Updated config"
response = authenticated_client.patch(
reverse("lighthouse-configurations"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_200_OK
second_data = response.json()["data"]
assert second_data["attributes"]["business_context"] == "Updated config"
# Verify it's the same config (same ID)
assert first_data["id"] == second_data["id"]
@patch("openai.OpenAI")
def test_lighthouse_tenant_config_retrieve(
self, mock_openai_client, authenticated_client, tenants_fixture
):
"""Test retrieving the singleton tenant config with proper provider and model validation"""
# Mock OpenAI client and models response
mock_models_response = Mock()
mock_models_response.data = [
Mock(id="gpt-4o"),
Mock(id="gpt-4o-mini"),
Mock(id="gpt-5"),
]
mock_openai_client.return_value.models.list.return_value = mock_models_response
# Create OpenAI provider configuration
provider_config = LighthouseProviderConfiguration.objects.create(
tenant_id=tenants_fixture[0].id,
provider_type="openai",
credentials=b'{"api_key": "sk-test1234567890T3BlbkFJtest1234567890"}',
is_active=True,
)
# Create provider models (simulating refresh)
LighthouseProviderModels.objects.create(
tenant_id=tenants_fixture[0].id,
provider_configuration=provider_config,
model_id="gpt-4o",
default_parameters={},
)
LighthouseProviderModels.objects.create(
tenant_id=tenants_fixture[0].id,
provider_configuration=provider_config,
model_id="gpt-4o-mini",
default_parameters={},
)
# Create tenant configuration with valid provider and model
config = LighthouseTenantConfiguration.objects.create(
tenant_id=tenants_fixture[0].id,
business_context="Test context",
default_provider="openai",
default_models={"openai": "gpt-4o"},
)
# Retrieve and verify the configuration
response = authenticated_client.get(reverse("lighthouse-configurations"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert data["id"] == str(config.id)
assert data["attributes"]["business_context"] == "Test context"
assert data["attributes"]["default_provider"] == "openai"
assert data["attributes"]["default_models"] == {"openai": "gpt-4o"}
def test_lighthouse_tenant_config_retrieve_not_found(self, authenticated_client):
"""Test GET when config doesn't exist returns 404"""
response = authenticated_client.get(reverse("lighthouse-configurations"))
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "not found" in response.json()["errors"][0]["detail"].lower()
def test_lighthouse_tenant_config_partial_update(
self, authenticated_client, tenants_fixture
):
"""Test updating tenant config fields"""
from api.models import LighthouseTenantConfiguration
# Create config first
config = LighthouseTenantConfiguration.objects.create(
tenant_id=tenants_fixture[0].id,
business_context="Original context",
default_provider="",
default_models={},
)
# Update it
payload = {
"data": {
"type": "lighthouse-configurations",
"attributes": {
"business_context": "Updated context for cloud security",
},
}
}
response = authenticated_client.patch(
reverse("lighthouse-configurations"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_200_OK
# Verify update
config.refresh_from_db()
assert config.business_context == "Updated context for cloud security"
def test_lighthouse_tenant_config_update_invalid_provider(
self, authenticated_client, tenants_fixture
):
"""Test validation fails when default_provider is not configured and active"""
from api.models import LighthouseTenantConfiguration
# Create config first
LighthouseTenantConfiguration.objects.create(
tenant_id=tenants_fixture[0].id,
business_context="Test",
)
# Try to set invalid provider
payload = {
"data": {
"type": "lighthouse-configurations",
"attributes": {
"default_provider": "nonexistent-provider",
},
}
}
response = authenticated_client.patch(
reverse("lighthouse-configurations"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "provider" in response.json()["errors"][0]["detail"].lower()
def test_lighthouse_tenant_config_update_invalid_json_format(
self, authenticated_client, tenants_fixture
):
"""Test that invalid JSON payload is rejected"""
from api.models import LighthouseTenantConfiguration
# Create config first
LighthouseTenantConfiguration.objects.create(
tenant_id=tenants_fixture[0].id,
business_context="Test",
)
# Send invalid JSON
response = authenticated_client.patch(
reverse("lighthouse-configurations"),
data="invalid json",
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestLighthouseProviderConfigViewSet:
"""Tests for LighthouseProviderConfiguration create validations"""
def test_invalid_provider_type(self, authenticated_client):
"""Add invalid provider (testprovider) should error"""
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "testprovider",
"credentials": {"api_key": "sk-testT3BlbkFJkey"},
},
}
}
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_openai_missing_credentials(self, authenticated_client):
"""OpenAI provider without credentials should error"""
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
},
}
}
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
@pytest.mark.parametrize(
"credentials",
[
{}, # empty credentials
{"token": "sk-testT3BlbkFJkey"}, # wrong key name
{"api_key": "ks-invalid-format"}, # wrong format
],
)
def test_openai_invalid_credentials(self, authenticated_client, credentials):
"""OpenAI provider with invalid credentials should error"""
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"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_openai_valid_credentials_success(self, authenticated_client):
"""OpenAI provider with valid sk-xxx format should succeed"""
valid_key = "sk-abc123T3BlbkFJxyz456"
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"credentials": {"api_key": valid_key},
},
}
}
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"]
masked_creds = data["attributes"].get("credentials")
assert masked_creds is not None
assert "api_key" in masked_creds
assert masked_creds["api_key"] == ("*" * len(valid_key))
def test_openai_provider_duplicate_per_tenant(self, authenticated_client):
"""If an OpenAI provider exists for tenant, creating again should error"""
valid_key = "sk-dup123T3BlbkFJdup456"
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"credentials": {"api_key": valid_key},
},
}
}
# 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_openai_patch_base_url_and_is_active(self, authenticated_client):
"""After creating, should be able to patch base_url and is_active"""
valid_key = "sk-patch123T3BlbkFJpatch456"
create_payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"credentials": {"api_key": valid_key},
},
}
}
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"]
patch_payload = {
"data": {
"type": "lighthouse-providers",
"id": provider_id,
"attributes": {
"base_url": "https://api.example.com/v1",
"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["base_url"] == "https://api.example.com/v1"
assert updated["is_active"] is False
def test_openai_patch_invalid_credentials(self, authenticated_client):
"""PATCH with invalid credentials.api_key should error (400)"""
valid_key = "sk-ok123T3BlbkFJok456"
create_payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"credentials": {"api_key": valid_key},
},
}
}
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"]
# Try patch with invalid api_key format
patch_payload = {
"data": {
"type": "lighthouse-providers",
"id": provider_id,
"attributes": {
"credentials": {"api_key": "ks-invalid-format"},
},
}
}
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_400_BAD_REQUEST
def test_openai_get_masking_and_fields_filter(self, authenticated_client):
valid_key = "sk-get123T3BlbkFJget456"
create_payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"credentials": {"api_key": valid_key},
},
}
}
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"]
# 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"]["api_key"]
assert masked == ("*" * len(valid_key))
# Fields filter should return decrypted credentials structure
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"] == valid_key
def test_delete_provider_updates_tenant_defaults(
self, authenticated_client, tenants_fixture
):
"""Deleting a provider config should clear tenant default_provider and its default_model entry."""
tenant = tenants_fixture[0]
# Create provider configuration to delete
provider = LighthouseProviderConfiguration.objects.create(
tenant_id=tenant.id,
provider_type="openai",
credentials=b'{"api_key":"sk-test123T3BlbkFJ"}',
is_active=True,
)
# Seed tenant defaults referencing the provider we will delete
cfg = LighthouseTenantConfiguration.objects.create(
tenant_id=tenant.id,
business_context="Test",
default_provider="openai",
default_models={"openai": "gpt-4o", "other": "model-x"},
)
# Delete via API and validate response
url = reverse("lighthouse-providers-detail", kwargs={"pk": str(provider.id)})
resp = authenticated_client.delete(url)
assert resp.status_code in (
status.HTTP_204_NO_CONTENT,
status.HTTP_200_OK,
)
# Tenant defaults should be updated
cfg.refresh_from_db()
assert cfg.default_provider == ""
assert "openai" not in cfg.default_models
# Unrelated entries should remain untouched
assert cfg.default_models.get("other") == "model-x"
+8 -3
View File
@@ -20,6 +20,7 @@ from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.github.github_provider import GithubProvider
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
from prowler.providers.m365.m365_provider import M365Provider
from prowler.providers.oraclecloud.oci_provider import OciProvider
class CustomOAuth2Client(OAuth2Client):
@@ -67,6 +68,7 @@ def return_prowler_provider(
| GithubProvider
| KubernetesProvider
| M365Provider
| OciProvider
]:
"""Return the Prowler provider class based on the given provider type.
@@ -74,7 +76,7 @@ def return_prowler_provider(
provider (Provider): The provider object containing the provider type and associated secrets.
Returns:
AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider: The corresponding provider class.
AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider | OciProvider: The corresponding provider class.
Raises:
ValueError: If the provider type specified in `provider.provider` is not supported.
@@ -92,6 +94,8 @@ def return_prowler_provider(
prowler_provider = M365Provider
case Provider.ProviderChoices.GITHUB.value:
prowler_provider = GithubProvider
case Provider.ProviderChoices.OCI.value:
prowler_provider = OciProvider
case _:
raise ValueError(f"Provider type {provider.provider} not supported")
return prowler_provider
@@ -147,6 +151,7 @@ def initialize_prowler_provider(
| GithubProvider
| KubernetesProvider
| M365Provider
| OciProvider
):
"""Initialize a Prowler provider instance based on the given provider type.
@@ -155,8 +160,8 @@ def initialize_prowler_provider(
mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration.
Returns:
AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider: An instance of the corresponding provider class
(`AwsProvider`, `AzureProvider`, `GcpProvider`, `GithubProvider`, `KubernetesProvider` or `M365Provider`) initialized with the
AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider | OciProvider: An instance of the corresponding provider class
(`AwsProvider`, `AzureProvider`, `GcpProvider`, `GithubProvider`, `KubernetesProvider`, `M365Provider` or `OciProvider`) initialized with the
provider's secrets.
"""
prowler_provider = return_prowler_provider(provider)
+18
View File
@@ -12,6 +12,24 @@ from api.models import StateChoices, Task
from api.v1.serializers import TaskSerializer
class DisablePaginationMixin:
disable_pagination_query_param = "page[disable]"
disable_pagination_truthy_values = {"true"}
def should_disable_pagination(self) -> bool:
if not hasattr(self, "request"):
return False
value = self.request.query_params.get(self.disable_pagination_query_param)
if value is None:
return False
return str(value).lower() in self.disable_pagination_truthy_values
def paginate_queryset(self, queryset):
if self.should_disable_pagination():
return None
return super().paginate_queryset(queryset)
class PaginateByPkMixin:
"""
Mixin to paginate on a list of PKs (cheaper than heavy JOINs),
@@ -0,0 +1,13 @@
import re
from rest_framework_json_api import serializers
class OpenAICredentialsSerializer(serializers.Serializer):
api_key = serializers.CharField()
def validate_api_key(self, value: str) -> str:
pattern = r"^sk-[\w-]+$"
if not re.match(pattern, value or ""):
raise serializers.ValidationError("Invalid OpenAI API key format.")
return value
@@ -239,6 +239,41 @@ from rest_framework_json_api import serializers
},
"required": ["github_app_id", "github_app_key"],
},
{
"type": "object",
"title": "Oracle Cloud Infrastructure (OCI) API Key Credentials",
"properties": {
"user": {
"type": "string",
"description": "The OCID of the user to authenticate with.",
},
"fingerprint": {
"type": "string",
"description": "The fingerprint of the API signing key.",
},
"key_file": {
"type": "string",
"description": "The path to the private key file for API signing. Either key_file or key_content must be provided.",
},
"key_content": {
"type": "string",
"description": "The content of the private key for API signing (base64 encoded). Either key_file or key_content must be provided.",
},
"tenancy": {
"type": "string",
"description": "The OCID of the tenancy.",
},
"region": {
"type": "string",
"description": "The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).",
},
"pass_phrase": {
"type": "string",
"description": "The passphrase for the private key, if encrypted.",
},
},
"required": ["user", "fingerprint", "tenancy", "region"],
},
]
}
)
+414
View File
@@ -6,8 +6,10 @@ from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.models import update_last_login
from django.contrib.auth.password_validation import validate_password
from django.db import IntegrityError
from drf_spectacular.utils import extend_schema_field
from jwt.exceptions import InvalidKeyError
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from rest_framework_json_api import serializers
from rest_framework_json_api.relations import SerializerMethodResourceRelatedField
@@ -25,6 +27,9 @@ from api.models import (
Invitation,
InvitationRoleRelationship,
LighthouseConfiguration,
LighthouseProviderConfiguration,
LighthouseProviderModels,
LighthouseTenantConfiguration,
Membership,
Processor,
Provider,
@@ -54,6 +59,7 @@ from api.v1.serializer_utils.integrations import (
S3ConfigSerializer,
SecurityHubConfigSerializer,
)
from api.v1.serializer_utils.lighthouse import OpenAICredentialsSerializer
from api.v1.serializer_utils.processors import ProcessorConfigField
from api.v1.serializer_utils.providers import ProviderSecretField
from prowler.lib.mutelist.mutelist import Mutelist
@@ -1353,6 +1359,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
serializer = KubernetesProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.M365.value:
serializer = M365ProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.OCI.value:
serializer = OracleCloudProviderSecret(data=secret)
else:
raise serializers.ValidationError(
{"provider": f"Provider type not supported {provider_type}"}
@@ -1466,6 +1474,19 @@ class GithubProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class OracleCloudProviderSecret(serializers.Serializer):
user = serializers.CharField()
fingerprint = serializers.CharField()
key_file = serializers.CharField(required=False)
key_content = serializers.CharField(required=False)
tenancy = serializers.CharField()
region = serializers.CharField()
pass_phrase = serializers.CharField(required=False)
class Meta:
resource_name = "provider-secrets"
class AWSRoleAssumptionProviderSecret(serializers.Serializer):
role_arn = serializers.CharField()
external_id = serializers.CharField()
@@ -2099,6 +2120,17 @@ class OverviewProviderSerializer(serializers.Serializer):
}
class OverviewProviderCountSerializer(serializers.Serializer):
id = serializers.CharField(source="provider")
count = serializers.IntegerField()
class JSONAPIMeta:
resource_name = "providers-count-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
class OverviewFindingSerializer(serializers.Serializer):
id = serializers.CharField(default="n/a")
new = serializers.IntegerField()
@@ -2750,6 +2782,16 @@ class LighthouseConfigCreateSerializer(RLSSerializer, BaseWriteSerializer):
"updated_at": {"read_only": True},
}
def validate_temperature(self, value):
if not 0 <= value <= 1:
raise ValidationError("Temperature must be between 0 and 1.")
return value
def validate_max_tokens(self, value):
if not 500 <= value <= 5000:
raise ValidationError("Max tokens must be between 500 and 5000.")
return value
def validate(self, attrs):
tenant_id = self.context.get("request").tenant_id
if LighthouseConfiguration.objects.filter(tenant_id=tenant_id).exists():
@@ -2758,6 +2800,11 @@ class LighthouseConfigCreateSerializer(RLSSerializer, BaseWriteSerializer):
"tenant_id": "Lighthouse configuration already exists for this tenant."
}
)
api_key = attrs.get("api_key")
if api_key is not None:
OpenAICredentialsSerializer(data={"api_key": api_key}).is_valid(
raise_exception=True
)
return super().validate(attrs)
def create(self, validated_data):
@@ -2802,6 +2849,24 @@ class LighthouseConfigUpdateSerializer(BaseWriteSerializer):
"max_tokens": {"required": False},
}
def validate_temperature(self, value):
if not 0 <= value <= 1:
raise ValidationError("Temperature must be between 0 and 1.")
return value
def validate_max_tokens(self, value):
if not 500 <= value <= 5000:
raise ValidationError("Max tokens must be between 500 and 5000.")
return value
def validate(self, attrs):
api_key = attrs.get("api_key", None)
if api_key is not None:
OpenAICredentialsSerializer(data={"api_key": api_key}).is_valid(
raise_exception=True
)
return super().validate(attrs)
def update(self, instance, validated_data):
api_key = validated_data.pop("api_key", None)
instance = super().update(instance, validated_data)
@@ -2931,3 +2996,352 @@ class TenantApiKeyUpdateSerializer(RLSSerializer, BaseWriteSerializer):
):
raise ValidationError("An API key with this name already exists.")
return value
# Lighthouse: Provider configurations
class LighthouseProviderConfigSerializer(RLSSerializer):
"""
Read serializer for LighthouseProviderConfiguration.
"""
# Decrypted credentials are only returned in to_representation when requested
credentials = serializers.JSONField(required=False, read_only=True)
class Meta:
model = LighthouseProviderConfiguration
fields = [
"id",
"inserted_at",
"updated_at",
"provider_type",
"base_url",
"is_active",
"credentials",
"url",
]
extra_kwargs = {
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"is_active": {"read_only": True},
"url": {"read_only": True, "view_name": "lighthouse-providers-detail"},
}
class JSONAPIMeta:
resource_name = "lighthouse-providers"
def to_representation(self, instance):
data = super().to_representation(instance)
# Support JSON:API fields filter: fields[lighthouse-providers]=credentials,base_url
fields_param = self.context.get("request", None) and self.context[
"request"
].query_params.get("fields[lighthouse-providers]", "")
creds = instance.credentials_decoded
requested_fields = (
[f.strip() for f in fields_param.split(",")] if fields_param else []
)
if "credentials" in requested_fields:
# Return full decrypted credentials JSON
data["credentials"] = creds
else:
# Return masked credentials by default
def mask_value(value):
if isinstance(value, str):
return "*" * len(value)
if isinstance(value, dict):
return {k: mask_value(v) for k, v in value.items()}
if isinstance(value, list):
return [mask_value(v) for v in value]
return value
# Always return masked credentials, even if creds is None
if creds is not None:
data["credentials"] = mask_value(creds)
else:
# If credentials_decoded returns None, return None for credentials field
data["credentials"] = None
return data
class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerializer):
"""
Create serializer for LighthouseProviderConfiguration.
Accepts credentials as JSON; stored encrypted via credentials_decoded.
"""
credentials = serializers.JSONField(write_only=True, required=True)
class Meta:
model = LighthouseProviderConfiguration
fields = [
"provider_type",
"base_url",
"credentials",
"is_active",
]
extra_kwargs = {
"is_active": {"required": False},
"base_url": {"required": False, "allow_null": True},
}
def create(self, validated_data):
credentials = validated_data.pop("credentials")
instance = LighthouseProviderConfiguration(**validated_data)
instance.tenant_id = self.context.get("tenant_id")
instance.credentials_decoded = credentials
try:
instance.save()
return instance
except IntegrityError:
raise ValidationError(
{
"provider_type": "Configuration for this provider already exists for the tenant."
}
)
def validate(self, attrs):
provider_type = attrs.get("provider_type")
credentials = attrs.get("credentials") or {}
if provider_type == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI:
try:
OpenAICredentialsSerializer(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)
class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
"""
Update serializer for LighthouseProviderConfiguration.
"""
credentials = serializers.JSONField(write_only=True, required=False)
class Meta:
model = LighthouseProviderConfiguration
fields = [
"id",
"provider_type",
"base_url",
"credentials",
"is_active",
]
extra_kwargs = {
"id": {"read_only": True},
"provider_type": {"read_only": True},
"base_url": {"required": False, "allow_null": True},
"is_active": {"required": False},
}
def update(self, instance, validated_data):
credentials = validated_data.pop("credentials", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if credentials is not None:
instance.credentials_decoded = credentials
instance.save()
return instance
def validate(self, attrs):
provider_type = getattr(self.instance, "provider_type", None)
credentials = attrs.get("credentials", None)
if (
credentials is not None
and provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI
):
try:
OpenAICredentialsSerializer(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)
# Lighthouse: Tenant configuration
class LighthouseTenantConfigSerializer(RLSSerializer):
"""
Read serializer for LighthouseTenantConfiguration.
"""
# Build singleton URL without pk
url = serializers.SerializerMethodField()
def get_url(self, obj):
request = self.context.get("request")
return reverse("lighthouse-configurations", request=request)
class Meta:
model = LighthouseTenantConfiguration
fields = [
"id",
"inserted_at",
"updated_at",
"business_context",
"default_provider",
"default_models",
"url",
]
extra_kwargs = {
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"url": {"read_only": True},
}
class LighthouseTenantConfigUpdateSerializer(BaseWriteSerializer):
class Meta:
model = LighthouseTenantConfiguration
fields = [
"id",
"business_context",
"default_provider",
"default_models",
]
extra_kwargs = {
"id": {"read_only": True},
}
def validate(self, attrs):
request = self.context.get("request")
tenant_id = self.context.get("tenant_id") or (
getattr(request, "tenant_id", None) if request else None
)
default_provider = attrs.get(
"default_provider", getattr(self.instance, "default_provider", "")
)
default_models = attrs.get(
"default_models", getattr(self.instance, "default_models", {})
)
if default_provider:
supported = set(LighthouseProviderConfiguration.LLMProviderChoices.values)
if default_provider not in supported:
raise ValidationError(
{"default_provider": f"Unsupported provider '{default_provider}'."}
)
if not LighthouseProviderConfiguration.objects.filter(
tenant_id=tenant_id, provider_type=default_provider, is_active=True
).exists():
raise ValidationError(
{
"default_provider": f"No active configuration found for '{default_provider}'."
}
)
if default_models is not None and not isinstance(default_models, dict):
raise ValidationError(
{"default_models": "Must be an object mapping provider -> model_id."}
)
for provider_type, model_id in (default_models or {}).items():
provider_cfg = LighthouseProviderConfiguration.objects.filter(
tenant_id=tenant_id, provider_type=provider_type, is_active=True
).first()
if not provider_cfg:
raise ValidationError(
{
"default_models": f"No active configuration for provider '{provider_type}'."
}
)
if not LighthouseProviderModels.objects.filter(
tenant_id=tenant_id,
provider_configuration=provider_cfg,
model_id=model_id,
).exists():
raise ValidationError(
{
"default_models": f"Invalid model '{model_id}' for provider '{provider_type}'."
}
)
return super().validate(attrs)
# Lighthouse: Provider models
class LighthouseProviderModelsSerializer(RLSSerializer):
"""
Read serializer for LighthouseProviderModels.
"""
provider_configuration = serializers.ResourceRelatedField(read_only=True)
class Meta:
model = LighthouseProviderModels
fields = [
"id",
"inserted_at",
"updated_at",
"provider_configuration",
"model_id",
"model_name",
"default_parameters",
"url",
]
extra_kwargs = {
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"url": {"read_only": True, "view_name": "lighthouse-models-detail"},
}
class LighthouseProviderModelsCreateSerializer(RLSSerializer, BaseWriteSerializer):
provider_configuration = serializers.ResourceRelatedField(
queryset=LighthouseProviderConfiguration.objects.all()
)
class Meta:
model = LighthouseProviderModels
fields = [
"provider_configuration",
"model_id",
"default_parameters",
]
extra_kwargs = {
"default_parameters": {"required": False},
}
class LighthouseProviderModelsUpdateSerializer(BaseWriteSerializer):
class Meta:
model = LighthouseProviderModels
fields = [
"id",
"default_parameters",
]
extra_kwargs = {
"id": {"read_only": True},
}
+22 -1
View File
@@ -17,6 +17,9 @@ from api.v1.views import (
InvitationAcceptViewSet,
InvitationViewSet,
LighthouseConfigViewSet,
LighthouseProviderConfigViewSet,
LighthouseProviderModelsViewSet,
LighthouseTenantConfigViewSet,
MembershipViewSet,
OverviewViewSet,
ProcessorViewSet,
@@ -34,12 +37,12 @@ from api.v1.views import (
ScheduleViewSet,
SchemaView,
TaskViewSet,
TenantApiKeyViewSet,
TenantFinishACSView,
TenantMembersViewSet,
TenantViewSet,
UserRoleRelationshipView,
UserViewSet,
TenantApiKeyViewSet,
)
router = routers.DefaultRouter(trailing_slash=False)
@@ -67,6 +70,16 @@ router.register(
basename="lighthouseconfiguration",
)
router.register(r"api-keys", TenantApiKeyViewSet, basename="api-key")
router.register(
r"lighthouse/providers",
LighthouseProviderConfigViewSet,
basename="lighthouse-providers",
)
router.register(
r"lighthouse/models",
LighthouseProviderModelsViewSet,
basename="lighthouse-models",
)
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
@@ -137,6 +150,14 @@ urlpatterns = [
),
name="provider_group-providers-relationship",
),
# Lighthouse tenant config as singleton endpoint
path(
"lighthouse/configuration",
LighthouseTenantConfigViewSet.as_view(
{"get": "list", "patch": "partial_update"}
),
name="lighthouse-configurations",
),
# API endpoint to start SAML SSO flow
path(
"auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate"
+329 -3
View File
@@ -1,5 +1,6 @@
import fnmatch
import glob
import json
import logging
import os
from datetime import datetime, timedelta, timezone
@@ -60,11 +61,13 @@ from tasks.tasks import (
backfill_scan_resource_summaries_task,
check_integration_connection_task,
check_lighthouse_connection_task,
check_lighthouse_provider_connection_task,
check_provider_connection_task,
delete_provider_task,
delete_tenant_task,
jira_integration_task,
perform_scan_task,
refresh_lighthouse_provider_models_task,
)
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
@@ -84,6 +87,8 @@ from api.filters import (
InvitationFilter,
LatestFindingFilter,
LatestResourceFilter,
LighthouseProviderConfigFilter,
LighthouseProviderModelsFilter,
MembershipFilter,
ProcessorFilter,
ProviderFilter,
@@ -106,6 +111,9 @@ from api.models import (
Integration,
Invitation,
LighthouseConfiguration,
LighthouseProviderConfiguration,
LighthouseProviderModels,
LighthouseTenantConfiguration,
Membership,
Processor,
Provider,
@@ -139,7 +147,7 @@ from api.utils import (
validate_invitation,
)
from api.uuid_utils import datetime_to_uuid7, uuid7_start
from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin
from api.v1.mixins import DisablePaginationMixin, PaginateByPkMixin, TaskManagementMixin
from api.v1.serializers import (
ComplianceOverviewAttributesSerializer,
ComplianceOverviewDetailSerializer,
@@ -160,8 +168,15 @@ from api.v1.serializers import (
LighthouseConfigCreateSerializer,
LighthouseConfigSerializer,
LighthouseConfigUpdateSerializer,
LighthouseProviderConfigCreateSerializer,
LighthouseProviderConfigSerializer,
LighthouseProviderConfigUpdateSerializer,
LighthouseProviderModelsSerializer,
LighthouseTenantConfigSerializer,
LighthouseTenantConfigUpdateSerializer,
MembershipSerializer,
OverviewFindingSerializer,
OverviewProviderCountSerializer,
OverviewProviderSerializer,
OverviewServiceSerializer,
OverviewSeveritySerializer,
@@ -307,7 +322,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.14.1"
spectacular_settings.VERSION = "1.15.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -1417,7 +1432,7 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
class ProviderViewSet(BaseRLSViewSet):
class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
queryset = Provider.objects.all()
serializer_class = ProviderSerializer
http_method_names = ["get", "post", "patch", "delete"]
@@ -3677,6 +3692,13 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
"each provider are considered in the aggregation to ensure accurate and up-to-date insights."
),
),
providers_count=extend_schema(
summary="Get provider counts grouped by type",
description=(
"Retrieve the number of providers grouped by provider type. "
"This endpoint counts every provider in the tenant, including those without completed scans."
),
),
findings=extend_schema(
summary="Get aggregated findings data",
description=(
@@ -3728,6 +3750,8 @@ class OverviewViewSet(BaseRLSViewSet):
def get_serializer_class(self):
if self.action == "providers":
return OverviewProviderSerializer
elif self.action == "providers_count":
return OverviewProviderCountSerializer
elif self.action == "findings":
return OverviewFindingSerializer
elif self.action == "findings_severity":
@@ -3815,6 +3839,36 @@ class OverviewViewSet(BaseRLSViewSet):
status=status.HTTP_200_OK,
)
@action(
detail=False,
methods=["get"],
url_path="providers/count",
url_name="providers-count",
)
def providers_count(self, request):
tenant_id = self.request.tenant_id
providers_qs = Provider.objects.filter(tenant_id=tenant_id)
if hasattr(self, "allowed_providers"):
allowed_ids = list(self.allowed_providers.values_list("id", flat=True))
if not allowed_ids:
overview = []
return Response(
self.get_serializer(overview, many=True).data,
status=status.HTTP_200_OK,
)
providers_qs = providers_qs.filter(id__in=allowed_ids)
overview = (
providers_qs.values("provider")
.annotate(count=Count("id"))
.order_by("provider")
)
return Response(
self.get_serializer(overview, many=True).data,
status=status.HTTP_200_OK,
)
@action(detail=False, methods=["get"], url_name="findings")
def findings(self, request):
tenant_id = self.request.tenant_id
@@ -4177,21 +4231,25 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
tags=["Lighthouse AI"],
summary="List all Lighthouse AI configurations",
description="Retrieve a list of all Lighthouse AI configurations.",
deprecated=True,
),
create=extend_schema(
tags=["Lighthouse AI"],
summary="Create a new Lighthouse AI configuration",
description="Create a new Lighthouse AI configuration with the specified details.",
deprecated=True,
),
partial_update=extend_schema(
tags=["Lighthouse AI"],
summary="Partially update a Lighthouse AI configuration",
description="Update certain fields of an existing Lighthouse AI configuration.",
deprecated=True,
),
destroy=extend_schema(
tags=["Lighthouse AI"],
summary="Delete a Lighthouse AI configuration",
description="Remove a Lighthouse AI configuration by its ID.",
deprecated=True,
),
connection=extend_schema(
tags=["Lighthouse AI"],
@@ -4199,6 +4257,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
description="Verify the connection to the OpenAI API for a specific Lighthouse AI configuration.",
request=None,
responses={202: OpenApiResponse(response=TaskSerializer)},
deprecated=True,
),
)
class LighthouseConfigViewSet(BaseRLSViewSet):
@@ -4249,6 +4308,273 @@ class LighthouseConfigViewSet(BaseRLSViewSet):
)
@extend_schema_view(
list=extend_schema(
tags=["Lighthouse AI"],
summary="List all LLM provider configs",
description="Retrieve all LLM provider configurations for the current tenant",
),
retrieve=extend_schema(
tags=["Lighthouse AI"],
summary="Retrieve LLM provider config",
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.",
),
partial_update=extend_schema(
tags=["Lighthouse AI"],
summary="Update LLM provider config",
description="Partially update a provider configuration (e.g., base_url, is_active).",
),
destroy=extend_schema(
tags=["Lighthouse AI"],
summary="Delete LLM provider config",
description="Delete a provider configuration. Any tenant defaults that reference this provider are cleared during deletion.",
),
)
class LighthouseProviderConfigViewSet(BaseRLSViewSet):
queryset = LighthouseProviderConfiguration.objects.all()
serializer_class = LighthouseProviderConfigSerializer
http_method_names = ["get", "post", "patch", "delete"]
filterset_class = LighthouseProviderConfigFilter
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return LighthouseProviderConfiguration.objects.none()
return LighthouseProviderConfiguration.objects.filter(
tenant_id=self.request.tenant_id
)
def get_serializer_class(self):
if self.action == "create":
return LighthouseProviderConfigCreateSerializer
elif self.action == "partial_update":
return LighthouseProviderConfigUpdateSerializer
elif self.action in ["connection", "refresh_models"]:
return TaskSerializer
return super().get_serializer_class()
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
read_serializer = LighthouseProviderConfigSerializer(
instance, context=self.get_serializer_context()
)
headers = self.get_success_headers(read_serializer.data)
return Response(
data=read_serializer.data,
status=status.HTTP_201_CREATED,
headers=headers,
)
def partial_update(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(
instance,
data=request.data,
partial=True,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
serializer.save()
read_serializer = LighthouseProviderConfigSerializer(
instance, context=self.get_serializer_context()
)
return Response(data=read_serializer.data, status=status.HTTP_200_OK)
@extend_schema(
tags=["Lighthouse AI"],
summary="Check LLM provider connection",
description="Validate provider credentials asynchronously and toggle is_active.",
request=None,
responses={202: OpenApiResponse(response=TaskSerializer)},
)
@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(
provider_config_id=str(instance.id), tenant_id=self.request.tenant_id
)
prowler_task = Task.objects.get(id=task.id)
serializer = TaskSerializer(prowler_task)
return Response(
data=serializer.data,
status=status.HTTP_202_ACCEPTED,
headers={
"Content-Location": reverse(
"task-detail", kwargs={"pk": prowler_task.id}
)
},
)
@extend_schema(
tags=["Lighthouse AI"],
summary="Refresh LLM models catalog",
description="Fetch available models for this provider configuration and upsert into catalog.",
request=None,
responses={202: OpenApiResponse(response=TaskSerializer)},
)
@action(
detail=True,
methods=["post"],
url_path="refresh-models",
url_name="refresh-models",
)
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(
provider_config_id=str(instance.id), tenant_id=self.request.tenant_id
)
prowler_task = Task.objects.get(id=task.id)
serializer = TaskSerializer(prowler_task)
return Response(
data=serializer.data,
status=status.HTTP_202_ACCEPTED,
headers={
"Content-Location": reverse(
"task-detail", kwargs={"pk": prowler_task.id}
)
},
)
@extend_schema_view(
list=extend_schema(
tags=["Lighthouse AI"],
summary="Get Lighthouse AI Tenant config",
description="Retrieve current tenant-level Lighthouse AI settings. Returns a single configuration object.",
),
partial_update=extend_schema(
tags=["Lighthouse AI"],
summary="Update Lighthouse AI Tenant config",
description="Update tenant-level settings. Validates that the default provider is configured and active and that default model IDs exist for the chosen providers. Auto-creates configuration if it doesn't exist.",
),
)
class LighthouseTenantConfigViewSet(BaseRLSViewSet):
"""
Singleton endpoint for tenant-level Lighthouse AI configuration.
This viewset implements a true singleton pattern:
- GET returns the single configuration object (or 404 if not found)
- PATCH updates/creates the configuration (upsert semantics)
- No ID is required in the URL
"""
queryset = LighthouseTenantConfiguration.objects.all()
serializer_class = LighthouseTenantConfigSerializer
http_method_names = ["get", "patch"]
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return LighthouseTenantConfiguration.objects.none()
return LighthouseTenantConfiguration.objects.filter(
tenant_id=self.request.tenant_id
)
def get_serializer_class(self):
if self.action == "partial_update":
return LighthouseTenantConfigUpdateSerializer
return super().get_serializer_class()
def get_object(self):
"""Retrieve the singleton instance for the current tenant."""
obj = LighthouseTenantConfiguration.objects.filter(
tenant_id=self.request.tenant_id
).first()
if obj is None:
raise NotFound("Tenant Lighthouse configuration not found")
self.check_object_permissions(self.request, obj)
return obj
def list(self, request, *args, **kwargs):
"""GET endpoint for singleton - returns single object, not an array."""
instance = self.get_object()
serializer = self.get_serializer(instance)
return Response(serializer.data)
def partial_update(self, request, *args, **kwargs):
"""PATCH endpoint for singleton - no pk required. Auto-creates if not exists."""
# Auto-create tenant config if it doesn't exist (upsert semantics)
instance, created = LighthouseTenantConfiguration.objects.get_or_create(
tenant_id=self.request.tenant_id,
defaults={},
)
# Extract attributes from JSON:API payload
try:
payload = json.loads(request.body)
attributes = payload.get("data", {}).get("attributes", {})
except (json.JSONDecodeError, AttributeError):
raise ValidationError("Invalid JSON:API payload")
serializer = self.get_serializer(instance, data=attributes, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
read_serializer = LighthouseTenantConfigSerializer(
instance, context=self.get_serializer_context()
)
return Response(read_serializer.data, status=status.HTTP_200_OK)
@extend_schema_view(
list=extend_schema(
tags=["Lighthouse AI"],
summary="List all LLM models",
description="List available LLM models per configured provider for the current tenant.",
),
retrieve=extend_schema(
tags=["Lighthouse AI"],
summary="Retrieve LLM model details",
description="Get details for a specific LLM model.",
),
)
class LighthouseProviderModelsViewSet(BaseRLSViewSet):
queryset = LighthouseProviderModels.objects.all()
serializer_class = LighthouseProviderModelsSerializer
filterset_class = LighthouseProviderModelsFilter
# Expose as read-only catalog collection
http_method_names = ["get"]
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return LighthouseProviderModels.objects.none()
return LighthouseProviderModels.objects.filter(tenant_id=self.request.tenant_id)
def get_serializer_class(self):
return super().get_serializer_class()
@extend_schema_view(
list=extend_schema(
tags=["Processor"],
-2
View File
@@ -1,7 +1,5 @@
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/v1/", include("api.v1.urls")),
]
+7 -1
View File
@@ -499,8 +499,14 @@ def providers_fixture(tenants_fixture):
alias="m365_testing",
tenant_id=tenant.id,
)
provider7 = Provider.objects.create(
provider="oci",
uid="ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
alias="oci_testing",
tenant_id=tenant.id,
)
return provider1, provider2, provider3, provider4, provider5, provider6
return provider1, provider2, provider3, provider4, provider5, provider6, provider7
@pytest.fixture
+8
View File
@@ -21,6 +21,8 @@ from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected im
AWSWellArchitected,
)
from prowler.lib.outputs.compliance.c5.c5_aws import AWSC5
from prowler.lib.outputs.compliance.c5.c5_azure import AzureC5
from prowler.lib.outputs.compliance.c5.c5_gcp import GCPC5
from prowler.lib.outputs.compliance.ccc.ccc_aws import CCC_AWS
from prowler.lib.outputs.compliance.ccc.ccc_azure import CCC_Azure
from prowler.lib.outputs.compliance.ccc.ccc_gcp import CCC_GCP
@@ -30,6 +32,7 @@ from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS
from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS
from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS
from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS
from prowler.lib.outputs.compliance.cis.cis_oci import OCICIS
from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS
from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS
from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS
@@ -87,6 +90,7 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name.startswith("iso27001_"), AzureISO27001),
(lambda name: name == "ccc_azure", CCC_Azure),
(lambda name: name == "prowler_threatscore_azure", ProwlerThreatScoreAzure),
(lambda name: name == "c5_azure", AzureC5),
],
"gcp": [
(lambda name: name.startswith("cis_"), GCPCIS),
@@ -95,6 +99,7 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name.startswith("iso27001_"), GCPISO27001),
(lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP),
(lambda name: name == "ccc_gcp", CCC_GCP),
(lambda name: name == "c5_gcp", GCPC5),
],
"kubernetes": [
(lambda name: name.startswith("cis_"), KubernetesCIS),
@@ -108,6 +113,9 @@ COMPLIANCE_CLASS_MAP = {
"github": [
(lambda name: name.startswith("cis_"), GithubCIS),
],
"oci": [
(lambda name: name.startswith("cis_"), OCICIS),
],
}
@@ -0,0 +1,163 @@
from typing import Dict, Set
import openai
from celery.utils.log import get_task_logger
from api.models import LighthouseProviderConfiguration, LighthouseProviderModels
logger = get_task_logger(__name__)
def _extract_openai_api_key(
provider_cfg: LighthouseProviderConfiguration,
) -> str | None:
"""
Safely extract the OpenAI API key from a provider configuration.
Args:
provider_cfg (LighthouseProviderConfiguration): The provider configuration instance
containing the credentials.
Returns:
str | None: The API key string if present and valid, otherwise None.
"""
creds = provider_cfg.credentials_decoded
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
return api_key
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`
to validate.
Returns:
dict: A result dictionary with the following keys:
- "connected" (bool): Whether the provider credentials are valid.
- "error" (str | None): The error message when not connected, otherwise None.
Side Effects:
- Updates and persists `is_active` on the `LighthouseProviderConfiguration`.
Raises:
LighthouseProviderConfiguration.DoesNotExist: If no configuration exists with the given ID.
"""
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()
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))
provider_cfg.is_active = False
provider_cfg.save()
return {"connected": False, "error": str(e)}
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.
Args:
provider_config_id (str): The primary key of the `LighthouseProviderConfiguration`
whose models should be refreshed.
Returns:
dict: A result dictionary with the following keys on success:
- "created" (int): Number of new model rows created.
- "updated" (int): Number of existing model rows updated.
- "deleted" (int): Number of stale model rows removed.
If an error occurs, the dictionary will contain an "error" (str) field instead.
Raises:
LighthouseProviderConfiguration.DoesNotExist: If no configuration exists with the given ID.
"""
provider_cfg = LighthouseProviderConfiguration.objects.get(pk=provider_config_id)
if (
provider_cfg.provider_type
!= LighthouseProviderConfiguration.LLMProviderChoices.OPENAI
):
return {
"created": 0,
"updated": 0,
"deleted": 0,
"error": "Unsupported provider type",
}
api_key = _extract_openai_api_key(provider_cfg)
if not api_key:
return {
"created": 0,
"updated": 0,
"deleted": 0,
"error": "API key is invalid or missing",
}
try:
client = openai.OpenAI(api_key=api_key)
models = client.models.list()
fetched_ids: Set[str] = {m.id for m in getattr(models, "data", [])}
except Exception as e: # noqa: BLE001
logger.warning("OpenAI models refresh failed: %s", str(e))
return {"created": 0, "updated": 0, "deleted": 0, "error": str(e)}
created = 0
updated = 0
for model_id in fetched_ids:
obj, was_created = LighthouseProviderModels.objects.update_or_create(
tenant_id=provider_cfg.tenant_id,
provider_configuration=provider_cfg,
model_id=model_id,
defaults={
"model_name": model_id, # OpenAI doesn't return a separate display name
"default_parameters": {},
},
)
if was_created:
created += 1
else:
updated += 1
# Delete stale models not present anymore
deleted, _ = (
LighthouseProviderModels.objects.filter(
tenant_id=provider_cfg.tenant_id, provider_configuration=provider_cfg
)
.exclude(model_id__in=fetched_ids)
.delete()
)
return {"created": created, "updated": updated, "deleted": deleted}
+22
View File
@@ -27,6 +27,10 @@ from tasks.jobs.integrations import (
upload_s3_integration,
upload_security_hub_integration,
)
from tasks.jobs.lighthouse_providers import (
check_lighthouse_provider_connection,
refresh_lighthouse_provider_models,
)
from tasks.jobs.report import generate_threatscore_report_job
from tasks.jobs.scan import (
aggregate_findings,
@@ -524,6 +528,24 @@ def check_lighthouse_connection_task(lighthouse_config_id: str, tenant_id: str =
return check_lighthouse_connection(lighthouse_config_id=lighthouse_config_id)
@shared_task(base=RLSTask, name="lighthouse-provider-connection-check")
@set_tenant
def check_lighthouse_provider_connection_task(
provider_config_id: str, tenant_id: str | None = None
) -> dict:
"""Task wrapper to validate provider credentials and set is_active."""
return check_lighthouse_provider_connection(provider_config_id=provider_config_id)
@shared_task(base=RLSTask, name="lighthouse-provider-models-refresh")
@set_tenant
def refresh_lighthouse_provider_models_task(
provider_config_id: str, tenant_id: str | None = None
) -> dict:
"""Task wrapper to refresh provider models catalog for the given configuration."""
return refresh_lighthouse_provider_models(provider_config_id=provider_config_id)
@shared_task(name="integration-check")
def check_integrations_task(tenant_id: str, provider_id: str, scan_id: str = None):
"""
+2 -1
View File
@@ -35,7 +35,8 @@ dashboard = dash.Dash(
# Logo
prowler_logo = html.Img(
src="https://prowler.com/wp-content/uploads/logo-dashboard.png", alt="Prowler Logo"
src="https://cdn.prod.website-files.com/68c4ec3f9fb7b154fbcb6e36/68ffb46d40ed7faa37a592a5_prowler-logo.png",
alt="Prowler Logo",
)
menu_icons = {
+43
View File
@@ -0,0 +1,43 @@
import warnings
from dashboard.common_methods import get_section_containers_3_levels
warnings.filterwarnings("ignore")
def get_table(data):
data["REQUIREMENTS_DESCRIPTION"] = (
data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"]
)
data["REQUIREMENTS_DESCRIPTION"] = data["REQUIREMENTS_DESCRIPTION"].apply(
lambda x: x[:150] + "..." if len(str(x)) > 150 else x
)
data["REQUIREMENTS_ATTRIBUTES_SECTION"] = data[
"REQUIREMENTS_ATTRIBUTES_SECTION"
].apply(lambda x: x[:80] + "..." if len(str(x)) > 80 else x)
data["REQUIREMENTS_ATTRIBUTES_SUBSECTION"] = data[
"REQUIREMENTS_ATTRIBUTES_SUBSECTION"
].apply(lambda x: x[:150] + "..." if len(str(x)) > 150 else x)
aux = data[
[
"REQUIREMENTS_DESCRIPTION",
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
]
return get_section_containers_3_levels(
aux,
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"REQUIREMENTS_DESCRIPTION",
)
+335
View File
@@ -0,0 +1,335 @@
# Prowler API Reference Documentation
This directory contains the API reference documentation for Prowler Cloud, integrated with Mintlify.
## Structure
```
api-reference/
├── README.md # This file
├── openapi.yaml # OpenAPI specification (auto-synced from api/src/backend/api/specs/v1.yaml)
├── introduction.mdx # API introduction and getting started guide
├── tokens/ # Authentication endpoints
│ ├── create.mdx # Create JWT token
│ ├── refresh.mdx # Refresh JWT token
│ └── switch.mdx # Switch tenant context
├── api-keys/ # API Keys endpoints
│ ├── list.mdx
│ ├── create.mdx
│ ├── retrieve.mdx
│ ├── update.mdx
│ └── revoke.mdx
├── users/ # User endpoints
│ └── me.mdx # Get current user
├── tenants/ # Tenant management
│ ├── list.mdx
│ ├── invitations-list.mdx
│ └── invitations-create.mdx
├── invitations/ # Invitation endpoints
│ └── accept.mdx
├── providers/ # Cloud provider management
│ ├── list.mdx
│ ├── create.mdx
│ ├── retrieve.mdx
│ ├── update.mdx
│ ├── delete.mdx
│ └── check-connection.mdx
├── scans/ # Security scan endpoints
│ ├── list.mdx
│ ├── retrieve.mdx
│ ├── compliance.mdx
│ ├── report.mdx
│ └── threatscore.mdx
├── findings/ # Security findings endpoints
│ ├── list.mdx
│ ├── retrieve.mdx
│ ├── latest.mdx
│ ├── services-regions.mdx
│ ├── metadata.mdx
│ └── metadata-latest.mdx
├── resources/ # Cloud resource endpoints
│ ├── list.mdx
│ ├── retrieve.mdx
│ ├── latest.mdx
│ ├── metadata.mdx
│ └── metadata-latest.mdx
├── compliance/ # Compliance framework endpoints
│ ├── list.mdx
│ ├── requirements.mdx
│ ├── attributes.mdx
│ └── metadata.mdx
├── overviews/ # Dashboard overview endpoints
│ ├── findings.mdx
│ ├── findings-severity.mdx
│ ├── providers.mdx
│ ├── providers-count.mdx
│ └── services.mdx
├── integrations/ # External integrations
│ ├── list.mdx
│ ├── create.mdx
│ ├── retrieve.mdx
│ ├── update.mdx
│ ├── delete.mdx
│ ├── check-connection.mdx
│ └── jira-dispatch.mdx
├── lighthouse/ # Lighthouse AI endpoints
│ ├── configuration-get.mdx
│ ├── configuration-update.mdx
│ ├── providers-list.mdx
│ ├── providers-create.mdx
│ └── models-list.mdx
├── processors/ # Finding processors (mutelists)
│ ├── list.mdx
│ └── create.mdx
├── schedules/ # Automated scan scheduling
│ └── daily.mdx
└── tasks/ # Asynchronous task management
├── list.mdx
└── retrieve.mdx
```
**Total: 131 endpoint documentation files organized in 18 groups**
**100% API Coverage** - All 123 operation IDs documented
## How It Works
The API documentation uses Mintlify's native OpenAPI support to automatically generate interactive API documentation from the OpenAPI specification file.
### Components
1. **openapi.yaml**: The source of truth for API endpoints, copied from `api/src/backend/api/specs/v1.yaml`
2. **MDX files**: Enhanced documentation for each endpoint with examples, tips, and additional context
3. **docs.json**: Mintlify configuration that references the OpenAPI spec
## Updating the Documentation
### When the API Spec Changes
When you update the OpenAPI specification in `api/src/backend/api/specs/v1.yaml`, you need to sync it to the docs:
```bash
# From the root of the repository
cp api/src/backend/api/specs/v1.yaml docs/api-reference/openapi.yaml
```
Consider automating this with a pre-commit hook or CI/CD pipeline.
### Adding New Endpoints
To document a new API endpoint:
1. **Update the OpenAPI spec** in `api/src/backend/api/specs/v1.yaml`
2. **Sync to docs**: Copy the spec to `docs/api-reference/openapi.yaml`
3. **Create MDX file**: Create a new `.mdx` file in the appropriate directory
4. **Update navigation**: Add the new page to `docs/docs.json` in the API Reference tab
#### MDX File Template
```mdx
---
title: "Endpoint Title"
api: "METHOD /api/v1/endpoint"
description: "Brief description of what this endpoint does."
---
Detailed description of the endpoint.
## Path Parameters
- `param` (required/optional) - Description
## Query Parameters
- `param` (required/optional) - Description
## Request Body
```json
{
"example": "request"
}
```
## Example Request
```bash
curl -X METHOD "https://api.prowler.com/api/v1/endpoint" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Description of the response.
```
### Testing Changes Locally
To preview documentation changes locally:
```bash
cd docs
mintlify dev
```
Then open http://localhost:3000 in your browser.
## Mintlify Configuration
The API documentation is configured in `docs/docs.json`:
- **openapi**: Points to the OpenAPI spec file
- **api.baseUrl**: The base URL for the API
- **api.auth.method**: Authentication method (bearer token)
- **api.playground.mode**: Interactive API playground mode
## API Endpoint Groups
### Core Endpoints
- **Authentication (3)**: JWT token management and tenant switching
- **API Keys (5)**: Programmatic API access management
- **Users (1)**: User profile and information
- **Tenants (3)**: Organization and invitation management
### Cloud Infrastructure
- **Providers (6)**: Cloud provider (AWS, Azure, GCP, etc.) configuration
- **Scans (5)**: Security scan execution and results
- **Findings (6)**: Security findings and vulnerabilities
- **Resources (5)**: Cloud resource inventory and metadata
### Compliance & Reporting
- **Compliance (4)**: Compliance framework assessments (CIS, PCI-DSS, etc.)
- **Overviews (5)**: Dashboard aggregated statistics
### Integrations & Automation
- **Integrations (7)**: External service integrations (S3, Security Hub, JIRA, Slack)
- **Lighthouse AI (5)**: AI-powered security insights configuration
- **Processors (2)**: Finding processors and mutelists
- **Schedules (1)**: Automated scan scheduling
### System
- **Tasks (2)**: Asynchronous task monitoring
## Best Practices
1. **Keep OpenAPI spec up to date**: Always update the source spec first, then sync to docs
2. **Add examples**: Include real-world examples in MDX files
3. **Use callouts**: Leverage Mintlify components like `<Note>`, `<Tip>`, `<Warning>` for important information
4. **Test playground**: Verify that the interactive API playground works for each endpoint
5. **Document filters**: For list endpoints, clearly document all available filters
6. **Include rate limits**: Document any rate limiting or pagination requirements
7. **Group related endpoints**: Keep related endpoints in the same directory
8. **Use consistent naming**: Follow the pattern `action.mdx` (e.g., `list.mdx`, `create.mdx`, `retrieve.mdx`)
## Resources
- [Mintlify OpenAPI Guide](https://mintlify.com/docs/api-playground/openapi-support)
- [Mintlify Components](https://mintlify.com/docs/content/components)
- [JSON:API Specification](https://jsonapi.org/)
- [Prowler Cloud API](https://api.prowler.com/api/v1/docs)
## Syncing OpenAPI Spec
The OpenAPI specification is maintained in the API repository and should be synced regularly:
```bash
# Using the provided sync script
cd docs
./sync-api-spec.sh
# Or manually
cp ../api/src/backend/api/specs/v1.yaml ./api-reference/openapi.yaml
```
The `sync-api-spec.sh` script automates this process and can be integrated into your CI/CD pipeline.
## Automation 🤖
**NEW**: Documentation can now be auto-generated from the OpenAPI specification!
### Quick Start with Automation
```bash
cd docs
# 1. Sync latest OpenAPI spec from GitHub
./sync-api-spec.sh
# 2. Generate MDX files automatically
python3 generate-api-docs.py
# 3. Test locally
mintlify dev
```
### Available Scripts
1. **`sync-api-spec.sh`** - Downloads latest OpenAPI spec from GitHub
2. **`generate-api-docs.py`** - Generates/updates MDX files from OpenAPI spec
### Features
- ✅ Auto-generate MDX files from OpenAPI spec
- ✅ Update existing or create new endpoints
- ✅ Dry-run mode to preview changes
- ✅ Proper directory structure and naming
- ✅ Frontmatter generation (title, API path, description)
- ✅ Parameter documentation extraction
- ✅ Request/response examples
### Documentation
See **[AUTOMATION.md](./AUTOMATION.md)** for complete automation guide including:
- Detailed usage instructions
- CI/CD integration examples
- Troubleshooting
- Best practices
## Recent Updates
### January 2025 - Complete API Documentation & Automation
#### Documentation Expansion
- ✅ **100% Coverage**: All 123 operation IDs documented (131 MDX files)
- ✅ **18 Endpoint Groups**: Complete organization
- ✅ **New Groups Added**:
- Provider Secrets (5 endpoints)
- Provider Groups (8 endpoints)
- Roles (8 endpoints)
- SAML Configuration (5 endpoints)
- Lighthouse AI expanded (17 endpoints with nested structure)
- Processors (5 endpoints)
- Tasks (3 endpoints)
- Compliance Overview (4 endpoints)
- Overviews (5 endpoints)
#### Automation Implementation
- ✅ **Auto-generation script**: `generate-api-docs.py`
- ✅ **Sync script**: `sync-api-spec.sh`
- ✅ **Complete automation guide**: AUTOMATION.md
- ✅ **CI/CD ready**: GitHub Actions example included
#### Quality Improvements
- ✅ Field name corrections verified against OpenAPI spec
- ✅ JSON:API compliance in all examples
- ✅ Proper nested structures (Provider Secrets, Tenant Memberships/Invitations)
- ✅ Comprehensive documentation files (CORRECTIONS.md, VERIFICATION.md, IMPROVEMENTS.md)
**Total coverage increased from 14 to 131 documented endpoints (843% increase)**
+182
View File
@@ -0,0 +1,182 @@
---
title: "Create API Key"
api: "POST /api/v1/api-keys"
description: "Create a new API key for the tenant."
---
Create a new API key for programmatic access to the Prowler API. The API key will be returned in the response and should be stored securely as it cannot be retrieved later.
## Request Body
The request must follow the JSON:API specification format:
```json
{
"data": {
"type": "api-keys",
"attributes": {
"name": "string",
"expires_at": "2024-12-31T23:59:59Z"
}
}
}
```
### Attributes
- `name` (required) - A descriptive name for the API key
- `expires_at` (optional) - Expiration date for the API key in ISO 8601 format
## Example Request
```bash
curl -X POST "https://api.prowler.com/api/v1/api-keys" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "api-keys",
"attributes": {
"name": "Production API Key",
"expires_at": "2025-12-31T23:59:59Z"
}
}
}'
```
## Response
### Success Response (201 Created)
Returns the created API key **including the full key value** (only shown once):
```json
{
"data": {
"type": "api-keys",
"id": "api-key-uuid",
"attributes": {
"name": "Production API Key",
"prefix": "pk_live_abc123",
"api_key": "pk_live_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": false,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": null
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
}
```
### Response Fields
- `id` - Unique UUID for the API key record
- `name` - Descriptive name you provided (3-100 characters)
- `prefix` - First characters of the key (for identification, read-only)
- `api_key` - **Full API key value** (only returned on creation, read-only)
- `expires_at` - Expiration date in ISO 8601 format (optional)
- `revoked` - Whether key has been revoked (always false on creation, read-only)
- `inserted_at` - When key was created (read-only)
- `last_used_at` - Last usage timestamp (null initially, read-only)
### Error Responses
**400 Bad Request** - Invalid expiration date
```json
{
"errors": [
{
"status": "400",
"title": "Invalid Expiration Date",
"detail": "Expiration date must be in the future"
}
]
}
```
**422 Unprocessable Entity** - Missing required fields
```json
{
"errors": [
{
"status": "422",
"title": "Validation Error",
"detail": "Name is required",
"source": {
"pointer": "/data/attributes/name"
}
}
]
}
```
**429 Too Many Requests** - API key limit reached
```json
{
"errors": [
{
"status": "429",
"title": "API Key Limit Exceeded",
"detail": "Maximum of 10 active API keys per tenant. Revoke unused keys to create new ones."
}
]
}
```
## Usage Example
After creating the API key, use it to authenticate API requests:
```bash
# Save the API key
API_KEY="pk_live_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
# Use it in requests
curl -X GET "https://api.prowler.com/api/v1/providers" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Security Best Practices
1. **Store Securely**: Save the API key in a secure location immediately (password manager, secrets vault, environment variables)
2. **Never Commit**: Do not commit API keys to version control systems
3. **Use Environment Variables**: Store keys in environment variables, not in code
4. **Set Expiration**: Always set an expiration date for API keys (recommended: 90 days)
5. **Rotate Regularly**: Rotate API keys every 90 days
6. **Limit Scope**: Use separate API keys for different environments (dev, staging, production)
7. **Monitor Usage**: Regularly check `last_used_at` to identify unused keys
8. **Revoke Unused**: Revoke API keys that are no longer needed
## Naming Conventions
Use descriptive names that include:
- **Environment**: Production, Staging, Development
- **Purpose**: CI/CD, Dashboard, Integration
- **Owner/Team**: Security Team, DevOps, Monitoring
Examples:
- "Production CI/CD Pipeline"
- "Staging Dashboard API Access"
- "Security Team Automation"
- "JIRA Integration - Production"
<Warning>
**CRITICAL:** The API key is only shown once in the creation response. It cannot be retrieved later. If you lose the key, you must revoke it and create a new one. Store it securely immediately after creation.
</Warning>
<Note>
API keys have the same permissions as the user who created them. For automated systems, consider creating a dedicated service account with limited permissions.
</Note>
<Tip>
**Automation Tip:** API keys are ideal for CI/CD pipelines, scheduled scans, and integrations. Unlike JWT tokens, they don't expire hourly and don't require refresh logic.
</Tip>
+124
View File
@@ -0,0 +1,124 @@
---
title: "List API Keys"
api: "GET /api/v1/api-keys"
description: "Retrieve a list of API keys for the tenant, with filtering support."
---
Retrieve a list of all API keys associated with your tenant. This endpoint supports various filtering options to help you find specific keys.
## Query Parameters
### Filtering
- `filter[name]` - Filter by exact API key name
- `filter[name__icontains]` - Filter by API key name (case-insensitive)
- `filter[prefix]` - Filter by API key prefix
- `filter[revoked]` - Filter by revocation status (boolean)
- `filter[expires_at]`, `filter[expires_at__gte]`, `filter[expires_at__lte]` - Filter by expiration date
- `filter[inserted_at]`, `filter[inserted_at__gte]`, `filter[inserted_at__lte]` - Filter by creation date
- `filter[search]` - General search term
### Pagination
- `page[number]` - Page number to retrieve
- `page[size]` - Number of results per page
### Sorting
- `sort` - Field to sort by. Available options: `name`, `-name`, `prefix`, `-prefix`, `revoked`, `-revoked`, `inserted_at`, `-inserted_at`, `expires_at`, `-expires_at`
### Field Selection
- `fields[api-keys]` - Specify which fields to return: `name`, `prefix`, `expires_at`, `revoked`, `inserted_at`, `last_used_at`, `entity`
### Include Related Resources
- `include` - Include related resources. Available: `entity`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/api-keys?page[size]=10&sort=-inserted_at" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns a paginated list of API keys with their metadata:
```json
{
"data": [
{
"type": "api-keys",
"id": "api-key-uuid-1",
"attributes": {
"name": "Production CI/CD Pipeline",
"prefix": "pk_live_abc123",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": false,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:22:00Z"
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
},
{
"type": "api-keys",
"id": "api-key-uuid-2",
"attributes": {
"name": "Staging Dashboard API Access",
"prefix": "pk_live_def456",
"expires_at": null,
"revoked": false,
"inserted_at": "2024-01-10T08:15:00Z",
"last_used_at": null
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
],
"meta": {
"page": {
"number": 1,
"size": 20,
"total": 2,
"total_pages": 1
}
},
"links": {
"self": "https://api.prowler.com/api/v1/api-keys?page[number]=1",
"first": "https://api.prowler.com/api/v1/api-keys?page[number]=1",
"last": "https://api.prowler.com/api/v1/api-keys?page[number]=1"
}
}
```
### Response Fields
- `id` (UUID) - Unique identifier for the API key record
- `name` (string) - Descriptive name (3-100 characters)
- `prefix` (string, read-only) - First characters of the key for identification
- `expires_at` (datetime, nullable) - Expiration date in ISO 8601 format
- `revoked` (boolean, read-only) - Whether key has been revoked
- `inserted_at` (datetime, read-only) - When key was created
- `last_used_at` (datetime, nullable, read-only) - Last usage timestamp
<Note>
The full API key value is never returned after creation. Only the prefix is shown for identification purposes.
</Note>
+89
View File
@@ -0,0 +1,89 @@
---
title: "Retrieve API Key"
api: "GET /api/v1/api-keys/{id}"
description: "Fetch detailed information about a specific API key by its ID."
---
Retrieve detailed information about a specific API key. Note that the full API key value is never returned, only metadata about the key.
## Path Parameters
- `id` (required) - The UUID of the API key to retrieve
## Query Parameters
### Field Selection
- `fields[api-keys]` - Specify which fields to return: `name`, `prefix`, `expires_at`, `revoked`, `inserted_at`, `last_used_at`, `entity`
### Include Related Resources
- `include` - Include related resources. Available: `entity`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/api-keys/{id}" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns detailed metadata about the API key:
```json
{
"data": {
"type": "api-keys",
"id": "api-key-uuid",
"attributes": {
"name": "Production CI/CD Pipeline",
"prefix": "pk_live_abc123",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": false,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:22:00Z"
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
}
```
### Response Fields
- `id` (UUID) - Unique identifier for the API key record
- `name` (string) - Descriptive name (3-100 characters)
- `prefix` (string, read-only) - First characters of the key for identification
- `expires_at` (datetime, nullable) - Expiration date in ISO 8601 format
- `revoked` (boolean, read-only) - Whether key has been revoked
- `inserted_at` (datetime, read-only) - When key was created
- `last_used_at` (datetime, nullable, read-only) - Last usage timestamp
### Error Responses
**404 Not Found** - API key does not exist
```json
{
"errors": [
{
"status": "404",
"title": "Not Found",
"detail": "API key not found"
}
]
}
```
<Note>
The full API key value is never returned after creation. Only the prefix is shown for identification purposes.
</Note>
+86
View File
@@ -0,0 +1,86 @@
---
title: "Revoke API Key"
api: "DELETE /api/v1/api-keys/{id}/revoke"
description: "Revoke an API key by its ID. This action is irreversible."
---
Revoke an API key, preventing it from being used for further API requests. This action cannot be undone.
## Path Parameters
- `id` (required) - The UUID of the API key to revoke
## Example Request
```bash
curl -X DELETE "https://api.prowler.com/api/v1/api-keys/{id}/revoke" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns the API key with `revoked` status set to `true`:
```json
{
"data": {
"type": "api-keys",
"id": "api-key-uuid",
"attributes": {
"name": "Production CI/CD Pipeline",
"prefix": "pk_live_abc123",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": true,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:22:00Z"
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
}
```
### Error Responses
**404 Not Found** - API key does not exist
```json
{
"errors": [
{
"status": "404",
"title": "Not Found",
"detail": "API key not found"
}
]
}
```
**409 Conflict** - API key already revoked
```json
{
"errors": [
{
"status": "409",
"title": "Already Revoked",
"detail": "This API key has already been revoked"
}
]
}
```
<Warning>
Revoking an API key is permanent and cannot be undone. Any applications using this key will immediately lose access. The `revoked` field will be set to `true` and the key will no longer authenticate API requests.
</Warning>
<Tip>
Monitor the `last_used_at` field before revoking to ensure the key is no longer in active use.
</Tip>
+88
View File
@@ -0,0 +1,88 @@
---
title: "Update API Key"
api: "PATCH /api/v1/api-keys/{id}"
description: "Modify certain fields of an existing API key without affecting other settings."
---
Partially update an API key's properties. This endpoint allows you to modify the name or expiration date without affecting other settings.
## Path Parameters
- `id` (required) - The UUID of the API key to update
## Request Body
The request must follow the JSON:API specification format:
```json
{
"data": {
"type": "api-keys",
"id": "string",
"attributes": {
"name": "string",
"expires_at": "2024-12-31T23:59:59Z"
}
}
}
```
### Attributes
- `name` (optional, string) - Update the API key name (3-100 characters)
<Note>
Only the `name` field can be updated. Other fields like `expires_at`, `prefix`, and `revoked` are read-only and cannot be modified after creation.
</Note>
## Example Request
```bash
curl -X PATCH "https://api.prowler.com/api/v1/api-keys/{id}" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "api-keys",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"attributes": {
"name": "Updated API Key Name"
}
}
}'
```
## Response
### Success Response (200 OK)
Returns the updated API key with modified metadata:
```json
{
"data": {
"type": "api-keys",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"attributes": {
"name": "Updated API Key Name",
"prefix": "pk_live_abc123",
"expires_at": "2025-12-31T23:59:59Z",
"revoked": false,
"inserted_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:22:00Z"
},
"relationships": {
"entity": {
"data": {
"type": "users",
"id": "user-uuid"
}
}
}
}
}
```
<Warning>
The full API key value is never returned in responses after creation. Only the prefix is shown for identification.
</Warning>
@@ -0,0 +1,7 @@
---
title: "Retrieve Compliance Attributes"
api: "GET /api/v1/compliance-overviews/attributes"
description: "Get compliance framework attributes."
---
Retrieve detailed attributes for compliance frameworks.
+17
View File
@@ -0,0 +1,17 @@
---
title: "List Compliance Overviews"
api: "GET /api/v1/compliance-overviews"
description: "Retrieve compliance framework coverage for scans."
---
Get compliance status across frameworks like CIS, PCI-DSS, HIPAA, etc.
## Query Parameters
- `filter[scan]` - Filter by scan UUID (required)
- `filter[framework]` - Filter by framework name
## Example
\`\`\`bash
curl "https://api.prowler.com/api/v1/compliance-overviews?filter[scan]=scan-uuid" \\
-H "Authorization: Bearer YOUR_API_KEY"
\`\`\`
@@ -0,0 +1,7 @@
---
title: "Retrieve Compliance Metadata"
api: "GET /api/v1/compliance-overviews/metadata"
description: "Get available compliance frameworks and metadata."
---
Fetch list of available compliance frameworks and their details.
@@ -0,0 +1,7 @@
---
title: "Retrieve Compliance Requirements"
api: "GET /api/v1/compliance-overviews/requirements"
description: "Get compliance requirements details."
---
Fetch specific requirements for compliance frameworks.
+109
View File
@@ -0,0 +1,109 @@
---
title: "List Latest Findings"
api: "GET /api/v1/findings/latest"
description: "Retrieve the latest findings from the most recent scans for each provider."
---
Retrieve the most recent findings from the latest scan for each provider in your account. This endpoint automatically filters to show only the latest scan results, making it ideal for dashboards and monitoring.
## Query Parameters
The endpoint supports the same filtering options as the regular findings list, except for the `inserted_at` filter (which is not required for this endpoint).
### Common Filters
**By Status:**
- `filter[status]`, `filter[status__in]` - Filter by status
- `filter[severity]`, `filter[severity__in]` - Filter by severity
- `filter[delta]`, `filter[delta__in]` - Filter by change status
- `filter[muted]` - Filter by mute status
**By Provider:**
- `filter[provider]`, `filter[provider__in]` - Filter by provider
- `filter[provider_type]`, `filter[provider_type__in]` - Filter by provider type
- `filter[provider_alias]`, `filter[provider_alias__icontains]` - Filter by provider alias
**By Resource:**
- `filter[resource_name]`, `filter[resource_name__icontains]` - Filter by resource name
- `filter[resource_type]`, `filter[resource_type__icontains]` - Filter by resource type
- `filter[region]`, `filter[region__in]` - Filter by region
- `filter[service]`, `filter[service__icontains]` - Filter by service
**By Check:**
- `filter[check_id]`, `filter[check_id__icontains]` - Filter by check ID
### Pagination & Sorting
- `page[number]`, `page[size]` - Pagination options
- `sort` - Sort by field
- `fields[findings]` - Select specific fields
- `include` - Include related resources
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/latest?filter[severity]=critical&filter[status]=FAIL&filter[provider_type]=aws" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns findings from the most recent scan for each provider:
```json
{
"data": [
{
"type": "findings",
"id": "finding-uuid",
"attributes": {
"uid": "prowler-aws-iam-password-policy-weak",
"check_id": "iam_password_policy_uppercase",
"status": "FAIL",
"status_extended": "IAM password policy does not require uppercase letters",
"severity": "medium",
"muted": false,
"muted_reason": null,
"delta": "new",
"inserted_at": "2024-01-20T10:30:00Z",
"updated_at": "2024-01-20T10:30:00Z",
"first_seen_at": "2024-01-20T10:30:00Z",
"check_metadata": {},
"raw_result": {}
},
"relationships": {
"scan": {
"data": {
"type": "scans",
"id": "scan-uuid"
}
},
"resources": {
"data": []
}
}
}
],
"meta": {
"page": {
"number": 1,
"size": 20,
"total": 45,
"total_pages": 3
}
},
"links": {
"self": "https://api.prowler.com/api/v1/findings/latest?page[number]=1",
"first": "https://api.prowler.com/api/v1/findings/latest?page[number]=1",
"next": "https://api.prowler.com/api/v1/findings/latest?page[number]=2",
"last": "https://api.prowler.com/api/v1/findings/latest?page[number]=3"
}
}
```
<Tip>
Use this endpoint for real-time monitoring and dashboards, as it automatically focuses on the latest scan results without requiring date filters.
</Tip>
+287
View File
@@ -0,0 +1,287 @@
---
title: "List Findings"
api: "GET /api/v1/findings"
description: "Retrieve a list of all findings with options for filtering by various criteria."
---
Retrieve security findings (vulnerabilities, misconfigurations, compliance violations) across your cloud infrastructure. This is one of the most frequently used endpoints for analyzing your security posture.
## Use Cases
- **Security Dashboard**: Display current security findings and trends
- **Compliance Reporting**: Filter findings by compliance framework
- **Vulnerability Management**: Track and remediate security issues
- **Alerting & Notifications**: Query new or critical findings for alerts
- **Risk Assessment**: Analyze findings by severity and service
- **Audit Trails**: Export findings for compliance audits
## Query Parameters
### Required Filters
- `filter[inserted_at]` - At least one variation of this filter is required
- `filter[inserted_at__gte]` - Filter by creation date (greater than or equal). Maximum date range is 7 days
- `filter[inserted_at__lte]` - Filter by creation date (less than or equal). Maximum date range is 7 days
### Filtering Options
**By Check:**
- `filter[check_id]`, `filter[check_id__icontains]`, `filter[check_id__in]` - Filter by check ID
**By Status:**
- `filter[status]` - Filter by status: `FAIL`, `PASS`, `MANUAL`
- `filter[severity]` - Filter by severity: `critical`, `high`, `medium`, `low`, `informational`
- `filter[delta]` - Filter by change status: `new`, `changed`
- `filter[muted]` - Filter by mute status (boolean)
**By Provider:**
- `filter[provider]`, `filter[provider__in]` - Filter by provider UUID
- `filter[provider_type]`, `filter[provider_type__in]` - Filter by provider type: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `oci`
- `filter[provider_alias]`, `filter[provider_alias__icontains]` - Filter by provider alias
- `filter[provider_uid]`, `filter[provider_uid__icontains]` - Filter by provider UID
**By Resource:**
- `filter[resource_name]`, `filter[resource_name__icontains]` - Filter by resource name
- `filter[resource_type]`, `filter[resource_type__icontains]` - Filter by resource type
- `filter[resource_uid]`, `filter[resource_uid__icontains]` - Filter by resource UID
- `filter[region]`, `filter[region__icontains]`, `filter[region__in]` - Filter by region
- `filter[service]`, `filter[service__icontains]` - Filter by cloud service
**By Scan:**
- `filter[scan]`, `filter[scan__in]` - Filter by scan UUID
### Pagination
- `page[number]` - Page number to retrieve
- `page[size]` - Number of results per page
### Sorting
- `sort` - Field to sort by: `status`, `-status`, `severity`, `-severity`, `check_id`, `-check_id`, `inserted_at`, `-inserted_at`, `updated_at`, `-updated_at`
### Field Selection
- `fields[findings]` - Specify which fields to return
### Include Related Resources
- `include` - Include related resources: `scan`, `resources`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings?filter[inserted_at__gte]=2024-01-01&filter[status]=FAIL&filter[severity]=high&page[size]=20" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns a paginated list of findings with full details:
```json
{
"data": [
{
"type": "findings",
"id": "finding-uuid",
"attributes": {
"uid": "prowler-aws-s3-bucket-unencrypted-123456",
"check_id": "s3_bucket_default_encryption",
"status": "FAIL",
"status_extended": "S3 Bucket 'my-bucket' does not have default encryption enabled",
"severity": "high",
"muted": false,
"muted_reason": null,
"delta": "new",
"inserted_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z",
"first_seen_at": "2024-01-15T10:30:00Z",
"check_metadata": {
"Provider": "aws",
"CheckID": "s3_bucket_default_encryption",
"CheckTitle": "Check if S3 buckets have default encryption enabled",
"CheckType": "Software and Configuration Checks",
"ServiceName": "s3",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:s3:::bucket_name",
"Severity": "high",
"ResourceType": "AwsS3Bucket",
"Description": "S3 buckets should have encryption at rest enabled...",
"Risk": "Unencrypted S3 buckets can expose sensitive data...",
"RelatedUrl": "https://docs.aws.amazon.com/...",
"Remediation": {
"Code": {
"CLI": "aws s3api put-bucket-encryption...",
"NativeIaC": "...",
"Other": "...",
"Terraform": "..."
},
"Recommendation": {
"Text": "Enable default encryption on S3 bucket",
"Url": "https://docs.aws.amazon.com/..."
}
},
"Categories": ["encryption"],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
},
"raw_result": {
"finding_unique_id": "...",
"finding_uid": "...",
"status": "FAIL",
"status_extended": "...",
"raw": {...}
}
},
"relationships": {
"scan": {
"data": {
"type": "scans",
"id": "scan-uuid"
}
},
"resources": {
"data": [
{
"type": "resources",
"id": "resource-uuid"
}
]
}
}
}
],
"meta": {
"page": {
"number": 1,
"size": 20,
"total": 156,
"total_pages": 8
}
},
"links": {
"self": "https://api.prowler.com/api/v1/findings?page[number]=1&page[size]=20",
"first": "https://api.prowler.com/api/v1/findings?page[number]=1&page[size]=20",
"next": "https://api.prowler.com/api/v1/findings?page[number]=2&page[size]=20",
"last": "https://api.prowler.com/api/v1/findings?page[number]=8&page[size]=20"
}
}
```
### Response Fields
**Core Finding Information:**
- `uid` (string, required) - Unique identifier for the finding (max 300 characters)
- `check_id` (string, required) - ID of the security check that generated this finding (max 100 characters)
- `status` (enum, required) - Finding status: `FAIL`, `PASS`, or `MANUAL`
- `status_extended` (string, nullable) - Detailed human-readable status message
- `severity` (enum, required) - Severity level: `critical`, `high`, `medium`, `low`, `informational`
- `delta` (enum, nullable) - Change status: `new`, `changed`, or `null`
**Mute Management:**
- `muted` (boolean) - Whether finding is muted (suppressed)
- `muted_reason` (string, nullable) - Reason for muting (3-500 characters)
**Timestamps:**
- `inserted_at` (datetime, read-only) - When finding was first created
- `updated_at` (datetime, read-only) - Last time finding was modified
- `first_seen_at` (datetime, nullable, read-only) - First time this finding was detected
**Check Details:**
- `check_metadata` (object) - Complete metadata about the security check including:
- Title, description, risk assessment
- Remediation steps and code examples
- Related documentation URLs
- Compliance frameworks and categories
- `raw_result` (object) - Complete raw output from the security check
**Relationships:**
- `scan` - Reference to the scan that generated this finding
- `resources` - References to affected cloud resources (read-only)
<Note>
Resource information (service, region, resource name, etc.) is available through the `resources` relationship. Use `include=resources` to fetch resource details along with findings.
</Note>
### Common Query Patterns
**Get all critical findings:**
```bash
curl "https://api.prowler.com/api/v1/findings?filter[severity]=critical&filter[status]=FAIL&filter[inserted_at__gte]=2024-01-01"
```
**Get new findings from last scan:**
```bash
curl "https://api.prowler.com/api/v1/findings/latest?filter[delta]=new&filter[status]=FAIL"
```
**Get findings for specific service:**
```bash
curl "https://api.prowler.com/api/v1/findings?filter[service]=s3&filter[inserted_at__gte]=2024-01-01"
```
**Get findings for specific region:**
```bash
curl "https://api.prowler.com/api/v1/findings?filter[region]=us-east-1&filter[inserted_at__gte]=2024-01-01"
```
**Get unmuted high/critical findings:**
```bash
curl "https://api.prowler.com/api/v1/findings?filter[severity__in]=critical,high&filter[muted]=false&filter[inserted_at__gte]=2024-01-01"
```
### Pagination
Use pagination parameters to navigate large result sets:
```bash
# First page, 50 results
curl "https://api.prowler.com/api/v1/findings?page[size]=50&page[number]=1&filter[inserted_at__gte]=2024-01-01"
# Next page
curl "https://api.prowler.com/api/v1/findings?page[size]=50&page[number]=2&filter[inserted_at__gte]=2024-01-01"
```
### Error Responses
**400 Bad Request** - Invalid filter or parameter
```json
{
"errors": [
{
"status": "400",
"title": "Invalid Filter",
"detail": "Date range cannot exceed 7 days for inserted_at filters"
}
]
}
```
**422 Unprocessable Entity** - Missing required filter
```json
{
"errors": [
{
"status": "422",
"title": "Validation Error",
"detail": "At least one variation of filter[inserted_at] is required"
}
]
}
```
<Note>
**Performance Tip:** Maximum date range for `inserted_at` filters is 7 days. For historical analysis, use multiple requests or export data to external storage.
</Note>
<Warning>
**Rate Limiting:** This endpoint is rate-limited to 100 requests per minute. For real-time monitoring, consider using webhooks or the `/findings/latest` endpoint.
</Warning>
<Tip>
Use `filter[delta]=new` to only retrieve findings discovered in the most recent scan, perfect for alerting on new security issues.
</Tip>
@@ -0,0 +1,79 @@
---
title: "Retrieve Latest Findings Metadata"
api: "GET /api/v1/findings/metadata/latest"
description: "Fetch unique metadata values from the latest scans for each provider. This is useful for dynamic filtering."
---
Retrieve metadata values such as services, regions, and resource types from the latest findings for each provider. This endpoint automatically filters to only include data from the most recent scans.
## Query Parameters
### Filtering Options
**By Check:**
- `filter[check_id]`, `filter[check_id__icontains]`, `filter[check_id__in]` - Filter by check ID
**By Status:**
- `filter[status]` - Filter by status: `FAIL`, `PASS`, `MANUAL`
- `filter[severity]` - Filter by severity: `critical`, `high`, `medium`, `low`, `informational`
- `filter[delta]` - Filter by change status: `new`, `changed`
- `filter[muted]` - Filter by mute status (boolean)
**By Provider:**
- `filter[provider]`, `filter[provider__in]` - Filter by provider UUID
- `filter[provider_type]`, `filter[provider_type__in]` - Filter by provider type: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `oci`
- `filter[provider_alias]`, `filter[provider_alias__icontains]` - Filter by provider alias
- `filter[provider_uid]`, `filter[provider_uid__icontains]` - Filter by provider UID
**By Resource:**
- `filter[resource_name]`, `filter[resource_name__icontains]` - Filter by resource name
- `filter[resource_type]`, `filter[resource_type__icontains]` - Filter by resource type
- `filter[resource_uid]`, `filter[resource_uid__icontains]` - Filter by resource UID
- `filter[region]`, `filter[region__icontains]`, `filter[region__in]` - Filter by region
- `filter[service]`, `filter[service__icontains]` - Filter by cloud service
### Sorting
- `sort` - Field to sort by: `status`, `-status`, `severity`, `-severity`, `check_id`, `-check_id`, `inserted_at`, `-inserted_at`, `updated_at`, `-updated_at`
### Field Selection
- `fields[findings-metadata]` - Specify which fields to return: `services`, `regions`, `resource_types`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/metadata/latest?filter[provider_type]=aws" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns unique metadata values from the latest scan findings:
```json
{
"data": {
"type": "findings-metadata",
"id": "metadata",
"attributes": {
"services": ["s3", "ec2", "iam", "vpc", "cloudtrail"],
"regions": ["us-east-1", "us-west-2", "eu-west-1"],
"resource_types": ["aws_s3_bucket", "aws_ec2_instance", "aws_iam_role"]
}
}
}
```
### Response Fields
- `services` (array of strings) - List of unique cloud services from latest scans
- `regions` (array of strings) - List of unique regions from latest scans
- `resource_types` (array of strings) - List of unique resource types from latest scans
<Note>
This endpoint is optimized for dashboard views where you only want to show data from the most recent scans.
</Note>
+88
View File
@@ -0,0 +1,88 @@
---
title: "Retrieve Findings Metadata"
api: "GET /api/v1/findings/metadata"
description: "Fetch unique metadata values from a set of findings. This is useful for dynamic filtering."
---
Retrieve metadata values such as services, regions, and resource types from your findings. This endpoint is useful for building dynamic filters in your application.
## Query Parameters
### Required Filters
- `filter[inserted_at]` - At least one variation of this filter is required
- `filter[inserted_at__gte]` - Filter by creation date (greater than or equal). Maximum date range is 7 days
- `filter[inserted_at__lte]` - Filter by creation date (less than or equal). Maximum date range is 7 days
### Filtering Options
**By Check:**
- `filter[check_id]`, `filter[check_id__icontains]`, `filter[check_id__in]` - Filter by check ID
**By Status:**
- `filter[status]` - Filter by status: `FAIL`, `PASS`, `MANUAL`
- `filter[severity]` - Filter by severity: `critical`, `high`, `medium`, `low`, `informational`
- `filter[delta]` - Filter by change status: `new`, `changed`
- `filter[muted]` - Filter by mute status (boolean)
**By Provider:**
- `filter[provider]`, `filter[provider__in]` - Filter by provider UUID
- `filter[provider_type]`, `filter[provider_type__in]` - Filter by provider type: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `oci`
- `filter[provider_alias]`, `filter[provider_alias__icontains]` - Filter by provider alias
- `filter[provider_uid]`, `filter[provider_uid__icontains]` - Filter by provider UID
**By Resource:**
- `filter[resource_name]`, `filter[resource_name__icontains]` - Filter by resource name
- `filter[resource_type]`, `filter[resource_type__icontains]` - Filter by resource type
- `filter[resource_uid]`, `filter[resource_uid__icontains]` - Filter by resource UID
- `filter[region]`, `filter[region__icontains]`, `filter[region__in]` - Filter by region
- `filter[service]`, `filter[service__icontains]` - Filter by cloud service
**By Scan:**
- `filter[scan]`, `filter[scan__in]` - Filter by scan UUID
### Sorting
- `sort` - Field to sort by: `status`, `-status`, `severity`, `-severity`, `check_id`, `-check_id`, `inserted_at`, `-inserted_at`, `updated_at`, `-updated_at`
### Field Selection
- `fields[findings-metadata]` - Specify which fields to return: `services`, `regions`, `resource_types`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/metadata?filter[inserted_at__gte]=2024-01-01" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns unique metadata values from the filtered findings:
```json
{
"data": {
"type": "findings-metadata",
"id": "metadata",
"attributes": {
"services": ["s3", "ec2", "iam", "rds", "lambda", "cloudtrail"],
"regions": ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"],
"resource_types": ["aws_s3_bucket", "aws_ec2_instance", "aws_iam_user", "aws_rds_db_instance"]
}
}
}
```
### Response Fields
- `services` (array of strings) - List of unique cloud services found in the findings
- `regions` (array of strings) - List of unique regions found in the findings
- `resource_types` (array of strings) - List of unique resource types found in the findings
<Note>
This endpoint is useful for populating dropdowns and filters in your UI with actual values from your findings data. The maximum date range is 7 days.
</Note>
+133
View File
@@ -0,0 +1,133 @@
---
title: "Retrieve Finding"
api: "GET /api/v1/findings/{id}"
description: "Fetch detailed information about a specific finding by its ID."
---
Retrieve complete details about a specific security finding, including check metadata, raw results, and affected resources.
## Path Parameters
- `id` (required) - The UUID of the finding to retrieve
## Query Parameters
### Field Selection
- `fields[findings]` - Specify which fields to return: `uid`, `delta`, `status`, `status_extended`, `severity`, `check_id`, `check_metadata`, `raw_result`, `inserted_at`, `updated_at`, `first_seen_at`, `muted`, `muted_reason`, `url`, `scan`, `resources`
### Include Related Resources
- `include` - Include related resources: `scan`, `resources`
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/{id}?include=scan,resources" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns detailed information about the finding:
```json
{
"data": {
"type": "findings",
"id": "finding-uuid",
"attributes": {
"uid": "prowler-aws-s3-bucket-unencrypted-123456",
"check_id": "s3_bucket_default_encryption",
"status": "FAIL",
"status_extended": "S3 Bucket 'my-bucket' does not have default encryption enabled",
"severity": "high",
"muted": false,
"muted_reason": null,
"delta": "new",
"inserted_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z",
"first_seen_at": "2024-01-15T10:30:00Z",
"check_metadata": {
"Provider": "aws",
"CheckID": "s3_bucket_default_encryption",
"CheckTitle": "Check if S3 buckets have default encryption enabled",
"CheckType": "Software and Configuration Checks",
"ServiceName": "s3",
"Severity": "high",
"ResourceType": "AwsS3Bucket",
"Description": "S3 buckets should have encryption at rest enabled...",
"Risk": "Unencrypted S3 buckets can expose sensitive data...",
"RelatedUrl": "https://docs.aws.amazon.com/...",
"Remediation": {
"Code": {
"CLI": "aws s3api put-bucket-encryption...",
"Terraform": "..."
},
"Recommendation": {
"Text": "Enable default encryption on S3 bucket",
"Url": "https://docs.aws.amazon.com/..."
}
},
"Categories": ["encryption"]
},
"raw_result": {
"finding_unique_id": "...",
"status": "FAIL",
"status_extended": "...",
"raw": {}
}
},
"relationships": {
"scan": {
"data": {
"type": "scans",
"id": "scan-uuid"
}
},
"resources": {
"data": [
{
"type": "resources",
"id": "resource-uuid"
}
]
}
}
}
}
```
### Response Fields
- `uid` (string, required) - Unique identifier for the finding (max 300 characters)
- `check_id` (string, required) - ID of the security check (max 100 characters)
- `status` (enum, required) - Finding status: `FAIL`, `PASS`, or `MANUAL`
- `status_extended` (string, nullable) - Detailed status message
- `severity` (enum, required) - Severity: `critical`, `high`, `medium`, `low`, `informational`
- `delta` (enum, nullable) - Change status: `new`, `changed`, or `null`
- `muted` (boolean) - Whether finding is muted
- `muted_reason` (string, nullable) - Reason for muting (3-500 characters)
- `inserted_at` (datetime, read-only) - Creation timestamp
- `updated_at` (datetime, read-only) - Last modification timestamp
- `first_seen_at` (datetime, nullable, read-only) - First detection timestamp
- `check_metadata` (object) - Complete check metadata
- `raw_result` (object) - Raw scan output
### Error Responses
**404 Not Found** - Finding does not exist
```json
{
"errors": [
{
"status": "404",
"title": "Not Found",
"detail": "Finding not found"
}
]
}
```
@@ -0,0 +1,52 @@
---
title: "Get Services and Regions"
api: "GET /api/v1/findings/findings_services_regions"
description: "Fetch services and regions affected in findings."
---
<Warning>
This endpoint is deprecated. Please use alternative filtering methods for retrieving services and regions information.
</Warning>
Retrieve a list of unique services and regions that have findings matching the specified criteria. This is useful for populating dynamic filters in user interfaces.
## Query Parameters
### Field Selection
- `fields[finding-dynamic-filters]` - Specify which fields to return: `services`, `regions`
### Filtering
The endpoint supports the same filtering options as the regular findings list endpoint to scope the services and regions returned.
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/findings/findings_services_regions?filter[provider_type]=aws&filter[status]=FAIL" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
### Success Response (200 OK)
Returns unique services and regions from findings:
```json
{
"data": {
"type": "finding-dynamic-filters",
"id": "filters",
"attributes": {
"services": ["s3", "ec2", "iam"],
"regions": ["us-east-1", "us-west-2"]
}
}
}
```
<Warning>
This endpoint is deprecated. Use `/api/v1/findings/metadata` or `/api/v1/findings/metadata/latest` instead.
</Warning>
@@ -0,0 +1,45 @@
---
title: "Check Integration Connection"
api: "POST /api/v1/integrations/{id}/connection"
description: "Try to verify integration connection."
---
Test the connection to an integration to ensure it is configured correctly and can communicate with the external service.
## Path Parameters
- `id` (required) - UUID of the integration to test
## Example Request
```bash
curl -X POST "https://api.prowler.com/api/v1/integrations/{id}/connection" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns a task object. The connection test is performed asynchronously.
```json
{
"data": {
"type": "tasks",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"attributes": {
"inserted_at": "2019-08-24T14:15:22Z",
"completed_at": "2019-08-24T14:15:22Z",
"name": "string",
"state": "available",
"result": null,
"task_args": null,
"metadata": null
}
}
}
```
<Note>
Use the returned task ID to check the connection test status via the Tasks API.
</Note>
@@ -0,0 +1,7 @@
---
title: "Create Integration"
api: "POST /api/v1/integrations"
description: "Configure a new integration."
---
Add integration with external systems.
@@ -0,0 +1,7 @@
---
title: "Delete Integration"
api: "DELETE /api/v1/integrations/{id}"
description: "Remove an integration."
---
Delete integration configuration.
@@ -0,0 +1,7 @@
---
title: "Create Jira Dispatch"
api: "POST /api/v1/integrations/{id}/jira/dispatches"
description: "Send findings to Jira."
---
Dispatch findings to Jira for tracking.
+7
View File
@@ -0,0 +1,7 @@
---
title: "List Integrations"
api: "GET /api/v1/integrations"
description: "Retrieve configured integrations."
---
Get all configured integrations (S3, Security Hub, Jira, etc).
@@ -0,0 +1,7 @@
---
title: "Retrieve Integration"
api: "GET /api/v1/integrations/{id}"
description: "Get integration details."
---
Fetch specific integration configuration.
@@ -0,0 +1,7 @@
---
title: "Update Integration"
api: "PATCH /api/v1/integrations/{id}"
description: "Update integration settings."
---
Modify integration configuration.
+121
View File
@@ -0,0 +1,121 @@
---
title: "API Reference"
description: "Comprehensive API documentation for Prowler Cloud"
---
## Welcome to the Prowler API
The Prowler API provides programmatic access to Prowler Cloud's security compliance scanning and reporting capabilities. This API follows RESTful principles and uses JSON:API specification for request and response formatting.
### Base URL
```
https://api.prowler.com/api/v1
```
### Authentication
All API requests require authentication using either:
- **JWT Token**: Obtained through user authentication
- **API Key**: Generated from your Prowler Cloud account
Include your authentication credentials in the `Authorization` header:
```bash
Authorization: Bearer YOUR_API_KEY_OR_JWT_TOKEN
```
### API Keys Management
You can manage your API keys through the [API Keys endpoints](/api-reference/api-keys/list). API keys provide:
- Programmatic access to your Prowler Cloud resources
- Fine-grained access control
- Expiration and revocation capabilities
- Usage tracking
### Request Format
All requests should include the following headers:
```
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json
Authorization: Bearer YOUR_API_KEY_OR_JWT_TOKEN
```
### Response Format
The API follows the [JSON:API specification](https://jsonapi.org/). Responses include:
- **data**: The primary data for the response
- **included**: Related resources (when using `include` parameter)
- **meta**: Metadata about the response
- **links**: Pagination links
### Pagination
List endpoints support pagination using the following query parameters:
- `page[number]`: The page number to retrieve
- `page[size]`: Number of results per page
### Filtering
Most list endpoints support filtering using query parameters in the format:
```
filter[field_name]=value
filter[field_name__operator]=value
```
Common operators:
- `__gte`: Greater than or equal
- `__lte`: Less than or equal
- `__icontains`: Case-insensitive contains
- `__in`: In a list of values
### Sorting
Use the `sort` parameter to order results:
```
sort=field_name # Ascending order
sort=-field_name # Descending order
```
### Field Selection
Optimize responses by requesting only specific fields:
```
fields[resource-type]=field1,field2,field3
```
### Rate Limits
API requests are subject to rate limiting to ensure service stability. Rate limit information is included in response headers.
### Support
For questions or issues with the API:
- Join our [Slack community](https://goto.prowler.com/slack)
- Visit [Prowler Cloud](https://cloud.prowler.com)
- Check our [GitHub repository](https://github.com/prowler-cloud/prowler)
## API Endpoints
Explore the complete API reference using the navigation menu. Main endpoint categories include:
- **API Keys**: Manage programmatic access credentials
- **Findings**: Security findings and vulnerabilities
- **Compliance**: Compliance frameworks and requirements
- **Scans**: Security scan operations and results
- **Providers**: Cloud provider configurations
- **Resources**: Cloud resources inventory
<Note>
The Prowler API is currently at version 1.15.0. We maintain backward compatibility and announce breaking changes in advance.
</Note>
+50
View File
@@ -0,0 +1,50 @@
---
title: "Accept Invitation"
api: "POST /api/v1/invitations/accept"
description: "Accept an invitation to an existing tenant."
---
Accept an invitation to join a tenant. This invitation cannot be expired and the emails must match.
## Request Body
The request must follow the JSON:API specification format:
```json
{
"data": {
"type": "invitations",
"attributes": {
"token": "invitation-token"
}
}
}
```
### Attributes
- `token` (required) - The invitation token received via email
## Example Request
```bash
curl -X POST "https://api.prowler.com/api/v1/invitations/accept" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "invitations",
"attributes": {
"token": "your-invitation-token"
}
}
}'
```
## Response
Returns a success response indicating you have been added to the tenant.
<Note>
The email address associated with your account must match the email address to which the invitation was sent.
</Note>
@@ -0,0 +1,7 @@
---
title: "List Lighthouse Config (Legacy)"
api: "GET /api/v1/lighthouse/configuration"
description: "Legacy configuration endpoint."
---
Legacy endpoint for configuration.
@@ -0,0 +1,7 @@
---
title: "Update Lighthouse Config (Legacy)"
api: "PATCH /api/v1/lighthouse/configuration"
description: "Update legacy configuration."
---
Legacy endpoint for updating configuration.
@@ -0,0 +1,19 @@
---
title: "Get Lighthouse AI Tenant Config"
api: "GET /api/v1/lighthouse/configuration"
description: "Retrieve current tenant-level Lighthouse AI settings."
---
Get your tenant's Lighthouse AI configuration including business context and default models.
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/lighthouse/configuration" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns tenant Lighthouse AI configuration.
@@ -0,0 +1,37 @@
---
title: "Update Lighthouse AI Tenant Config"
api: "PATCH /api/v1/lighthouse/configuration"
description: "Update tenant-level Lighthouse AI settings."
---
Update your tenant's Lighthouse AI configuration including business context and default providers/models.
## Request Body
```json
{
"data": {
"type": "lighthouse-config",
"attributes": {
"business_context": "Financial services company focusing on security and compliance",
"default_provider": "openai",
"default_models": {
"chat": "gpt-4",
"analysis": "gpt-4"
}
}
}
}
```
## Example Request
```bash
curl -X PATCH "https://api.prowler.com/api/v1/lighthouse/configuration" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns updated configuration.
@@ -0,0 +1,7 @@
---
title: "Create Lighthouse Configuration"
api: "POST /api/v1/lighthouse/configurations"
description: "Create AI configuration profile."
---
Configure Lighthouse AI settings.
@@ -0,0 +1,7 @@
---
title: "Delete Lighthouse Configuration"
api: "DELETE /api/v1/lighthouse/configurations/{id}"
description: "Remove configuration profile."
---
Delete Lighthouse configuration.
@@ -0,0 +1,7 @@
---
title: "List Lighthouse Configurations"
api: "GET /api/v1/lighthouse/configurations"
description: "Retrieve Lighthouse AI configurations."
---
Get Lighthouse configuration profiles.
@@ -0,0 +1,7 @@
---
title: "Retrieve Lighthouse Configuration"
api: "GET /api/v1/lighthouse/configurations/{id}"
description: "Get configuration details."
---
Fetch Lighthouse configuration profile.
@@ -0,0 +1,7 @@
---
title: "Test Lighthouse Configuration"
api: "POST /api/v1/lighthouse/configurations/{id}/connection"
description: "Test configuration."
---
Verify Lighthouse configuration works.
@@ -0,0 +1,7 @@
---
title: "Update Lighthouse Configuration"
api: "PATCH /api/v1/lighthouse/configurations/{id}"
description: "Update configuration profile."
---
Modify Lighthouse AI settings.
@@ -0,0 +1,25 @@
---
title: "List LLM Models"
api: "GET /api/v1/lighthouse/models"
description: "List available LLM models per configured provider."
---
Retrieve all available LLM models from your configured providers.
## Query Parameters
- `filter[provider_type]` - Filter by provider type
- `filter[model_id]`, `filter[model_id__icontains]` - Filter by model ID
- `page[number]`, `page[size]` - Pagination
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/lighthouse/models?filter[provider_type]=openai" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns list of available LLM models with their parameters.
@@ -0,0 +1,7 @@
---
title: "List Lighthouse Models"
api: "GET /api/v1/lighthouse/models"
description: "Retrieve available AI models."
---
Get list of available LLM models.
@@ -0,0 +1,7 @@
---
title: "Retrieve Lighthouse Model"
api: "GET /api/v1/lighthouse/models/{id}"
description: "Get AI model details."
---
Fetch information about a specific model.
@@ -0,0 +1,40 @@
---
title: "Create LLM Provider Config"
api: "POST /api/v1/lighthouse/providers"
description: "Create configuration for an LLM provider."
---
Configure an LLM provider for use with Lighthouse AI.
## Request Body
```json
{
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"base_url": "https://api.openai.com/v1",
"credentials": {
"api_key": "sk-..."
}
}
}
}
```
## Example Request
```bash
curl -X POST "https://api.prowler.com/api/v1/lighthouse/providers" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns the created provider configuration.
<Note>
Only one configuration per provider type is allowed per tenant.
</Note>
@@ -0,0 +1,25 @@
---
title: "List LLM Provider Configs"
api: "GET /api/v1/lighthouse/providers"
description: "Retrieve all LLM provider configurations for Lighthouse AI."
---
Retrieve all configured LLM provider configurations (OpenAI, etc.) for Lighthouse AI.
## Query Parameters
- `filter[provider_type]` - Filter by provider type: `openai`
- `filter[is_active]` - Filter by active status (boolean)
- `page[number]`, `page[size]` - Pagination
## Example Request
```bash
curl -X GET "https://api.prowler.com/api/v1/lighthouse/providers" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json"
```
## Response
Returns list of LLM provider configurations.
@@ -0,0 +1,21 @@
---
title: "Create Lighthouse Provider"
api: "POST /api/v1/lighthouse/providers"
description: "Add AI provider for Lighthouse."
---
Configure an LLM provider.
## Request Body
\`\`\`json
{
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai",
"api_key": "sk-...",
"name": "OpenAI Production"
}
}
}
\`\`\`
@@ -0,0 +1,7 @@
---
title: "Delete Lighthouse Provider"
api: "DELETE /api/v1/lighthouse/providers/{id}"
description: "Remove AI provider."
---
Delete LLM provider configuration.
@@ -0,0 +1,7 @@
---
title: "List Lighthouse Providers"
api: "GET /api/v1/lighthouse/providers"
description: "Retrieve LLM providers for Lighthouse AI."
---
Get configured AI/LLM providers (OpenAI, Azure OpenAI, etc).
@@ -0,0 +1,7 @@
---
title: "Refresh Lighthouse Provider Models"
api: "POST /api/v1/lighthouse/providers/{id}/refresh-models"
description: "Refresh available models from provider."
---
Update the list of available AI models.
@@ -0,0 +1,7 @@
---
title: "Retrieve Lighthouse Provider"
api: "GET /api/v1/lighthouse/providers/{id}"
description: "Get AI provider details."
---
Fetch LLM provider configuration.
@@ -0,0 +1,7 @@
---
title: "Test Lighthouse Provider Connection"
api: "POST /api/v1/lighthouse/providers/{id}/connection"
description: "Test AI provider connectivity."
---
Verify LLM provider is properly configured.

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