Merge branch 'master' of https://github.com/prowler-cloud/prowler into github-poc

This commit is contained in:
HugoPBrito
2024-12-03 17:03:34 +01:00
134 changed files with 5048 additions and 1269 deletions
+89
View File
@@ -0,0 +1,89 @@
#### Important Note ####
# This file is used to store environment variables for the Prowler App.
# For production, it is recommended to use a secure method to store these variables and change the default secret keys.
#### Prowler UI Configuration ####
PROWLER_UI_VERSION="latest"
SITE_URL=http://localhost:3000
API_BASE_URL=http://prowler-api:8080/api/v1
AUTH_TRUST_HOST=true
UI_PORT=3000
# openssl rand -base64 32
AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8="
#### Prowler API Configuration ####
PROWLER_API_VERSION="latest"
# PostgreSQL settings
# If running Django and celery on host, use 'localhost', else use 'postgres-db'
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
POSTGRES_ADMIN_USER=prowler_admin
POSTGRES_ADMIN_PASSWORD=postgres
POSTGRES_USER=prowler
POSTGRES_PASSWORD=postgres
POSTGRES_DB=prowler_db
# Valkey settings
# If running Valkey and celery on host, use localhost, else use 'valkey'
VALKEY_HOST=valkey
VALKEY_PORT=6379
VALKEY_DB=0
# Django settings
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api
DJANGO_BIND_ADDRESS=0.0.0.0
DJANGO_PORT=8080
DJANGO_DEBUG=False
DJANGO_SETTINGS_MODULE=config.django.production
# Select one of [ndjson|human_readable]
DJANGO_LOGGING_FORMATTER=human_readable
# Select one of [DEBUG|INFO|WARNING|ERROR|CRITICAL]
# Applies to both Django and Celery Workers
DJANGO_LOGGING_LEVEL=INFO
DJANGO_WORKERS=4 # Defaults to the maximum available based on CPU cores if not set.
DJANGO_ACCESS_TOKEN_LIFETIME=30 # Token lifetime is in minutes
DJANGO_REFRESH_TOKEN_LIFETIME=1440 # Token lifetime is in minutes
DJANGO_CACHE_MAX_AGE=3600
DJANGO_STALE_WHILE_REVALIDATE=60
DJANGO_MANAGE_DB_PARTITIONS=True
# openssl genrsa -out private.pem 2048
DJANGO_TOKEN_SIGNING_KEY="-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDs4e+kt7SnUJek
6V5r9zMGzXCoU5qnChfPiqu+BgANyawz+MyVZPs6RCRfeo6tlCknPQtOziyXYM2I
7X+qckmuzsjqp8+u+o1mw3VvUuJew5k2SQLPYwsiTzuFNVJEOgRo3hywGiGwS2iv
/5nh2QAl7fq2qLqZEXQa5+/xJlQggS1CYxOJgggvLyra50QZlBvPve/AxKJ/EV/Q
irWTZU5lLNI8sH2iZR05vQeBsxZ0dCnGMT+vGl+cGkqrvzQzKsYbDmabMcfTYhYi
78fpv6A4uharJFHayypYBjE39PwhMyyeycrNXlpm1jpq+03HgmDuDMHydk1tNwuT
nEC7m7iNAgMBAAECggEAA2m48nJcJbn9SVi8bclMwKkWmbJErOnyEGEy2sTK3Of+
NWx9BB0FmqAPNxn0ss8K7cANKOhDD7ZLF9E2MO4/HgfoMKtUzHRbM7MWvtEepldi
nnvcUMEgULD8Dk4HnqiIVjt3BdmGiTv46OpBnRWrkSBV56pUL+7msZmMZTjUZvh2
ZWv0+I3gtDIjo2Zo/FiwDV7CfwRjJarRpYUj/0YyuSA4FuOUYl41WAX1I301FKMH
xo3jiAYi1s7IneJ16OtPpOA34Wg5F6ebm/UO0uNe+iD4kCXKaZmxYQPh5tfB0Qa3
qj1T7GNpFNyvtG7VVdauhkb8iu8X/wl6PCwbg0RCKQKBgQD9HfpnpH0lDlHMRw9K
X7Vby/1fSYy1BQtlXFEIPTN/btJ/asGxLmAVwJ2HAPXWlrfSjVAH7CtVmzN7v8oj
HeIHfeSgoWEu1syvnv2AMaYSo03UjFFlfc/GUxF7DUScRIhcJUPCP8jkAROz9nFv
DByNjUL17Q9r43DmDiRsy0IFqQKBgQDvlJ9Uhl+Sp7gRgKYwa/IG0+I4AduAM+Gz
Dxbm52QrMGMTjaJFLmLHBUZ/ot+pge7tZZGws8YR8ufpyMJbMqPjxhIvRRa/p1Tf
E3TQPW93FMsHUvxAgY3MV5MzXFPhlNAKb+akP/RcXUhetGAuZKLubtDCWa55ZQuL
wj2OS+niRQKBgE7K8zUqNi6/22S8xhy/2GPgB1qPObbsABUofK0U6CAGLo6te+gc
6Jo84IyzFtQbDNQFW2Fr+j1m18rw9AqkdcUhQndiZS9AfG07D+zFB86LeWHt4DS4
ymIRX8Kvaak/iDcu/n3Mf0vCrhB6aetImObTj4GgrwlFvtJOmrYnO8EpAoGAIXXP
Xt25gWD9OyyNiVu6HKwA/zN7NYeJcRmdaDhO7B1A6R0x2Zml4AfjlbXoqOLlvLAf
zd79vcoAC82nH1eOPiSOq51plPDI0LMF8IN0CtyTkn1Lj7LIXA6rF1RAvtOqzppc
SvpHpZK9pcRpXnFdtBE0BMDDtl6fYzCIqlP94UUCgYEAnhXbAQMF7LQifEm34Dx8
BizRMOKcqJGPvbO2+Iyt50O5X6onU2ITzSV1QHtOvAazu+B1aG9pEuBFDQ+ASxEu
L9ruJElkOkb/o45TSF6KCsHd55ReTZ8AqnRjf5R+lyzPqTZCXXb8KTcRvWT4zQa3
VxyT2PnaSqEcexWUy4+UXoQ=
-----END PRIVATE KEY-----"
# openssl rsa -in private.pem -pubout -out public.pem
DJANGO_TOKEN_VERIFYING_KEY="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7OHvpLe0p1CXpOlea/cz
Bs1wqFOapwoXz4qrvgYADcmsM/jMlWT7OkQkX3qOrZQpJz0LTs4sl2DNiO1/qnJJ
rs7I6qfPrvqNZsN1b1LiXsOZNkkCz2MLIk87hTVSRDoEaN4csBohsEtor/+Z4dkA
Je36tqi6mRF0Gufv8SZUIIEtQmMTiYIILy8q2udEGZQbz73vwMSifxFf0Iq1k2VO
ZSzSPLB9omUdOb0HgbMWdHQpxjE/rxpfnBpKq780MyrGGw5mmzHH02IWIu/H6b+g
OLoWqyRR2ssqWAYxN/T8ITMsnsnKzV5aZtY6avtNx4Jg7gzB8nZNbTcLk5xAu5u4
jQIDAQAB
-----END PUBLIC KEY-----"
# openssl rand -base64 32
DJANGO_SECRETS_ENCRYPTION_KEY="oE/ltOhp/n1TdbHjVmzcjDPLcLA41CVI/4Rk+UB5ESc="
+2 -2
View File
@@ -1,4 +1,4 @@
name: "API - Pull Request"
name: "API - Pull Request"
on:
push:
@@ -148,7 +148,7 @@ jobs:
working-directory: ./api
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true'
run: |
poetry run pytest -n auto --cov=./src/backend --cov-report=xml src/backend
poetry run pytest --cov=./src/backend --cov-report=xml src/backend
- name: Upload coverage reports to Codecov
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true'
uses: codecov/codecov-action@v5
+6 -1
View File
@@ -12,6 +12,7 @@ build/
/dist/
*.egg-info/
*/__pycache__/*.pyc
.idea/
# Session
Session.vim
@@ -46,7 +47,8 @@ junit-reports/
*.tfstate
# .env
.env*
ui/.env*
api/.env*
# Coverage
.coverage*
@@ -55,3 +57,6 @@ coverage*
# Node
node_modules
# Persistent data
_data/
+2 -1
View File
@@ -97,12 +97,13 @@ repos:
- id: safety
name: safety
description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities"
entry: bash -c 'safety check --ignore 70612'
entry: bash -c 'safety check --ignore 70612,66963'
language: system
- id: vulture
name: vulture
description: "Vulture finds unused code in Python programs."
entry: bash -c 'vulture --exclude "contrib" --min-confidence 100 .'
exclude: 'api/src/backend/'
language: system
files: '.*\.py'
+109 -12
View File
@@ -45,6 +45,14 @@
**Prowler** is an Open Source security tool to perform AWS, Azure, Google Cloud and Kubernetes security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness, and also remediations! We have Prowler CLI (Command Line Interface) that we call Prowler Open Source and a service on top of it that we call <a href="https://prowler.com">Prowler SaaS</a>.
## Prowler App
Prowler App is a web application that allows you to run Prowler in your cloud provider accounts and visualize the results in a user-friendly interface.
![Prowler App](docs/img/overview.png)
>More details at [Prowler App Documentation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-app-installation)
## Prowler CLI
```console
@@ -63,42 +71,121 @@ It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, Fe
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) |
|---|---|---|---|---|
| AWS | 553 | 77 -> `prowler aws --list-services` | 30 -> `prowler aws --list-compliance` | 9 -> `prowler aws --list-categories` |
| AWS | 561 | 81 -> `prowler aws --list-services` | 30 -> `prowler aws --list-compliance` | 9 -> `prowler aws --list-categories` |
| GCP | 77 | 13 -> `prowler gcp --list-services` | 3 -> `prowler gcp --list-compliance` | 2 -> `prowler gcp --list-categories`|
| Azure | 138 | 17 -> `prowler azure --list-services` | 4 -> `prowler azure --list-compliance` | 2 -> `prowler azure --list-categories` |
| Azure | 139 | 18 -> `prowler azure --list-services` | 4 -> `prowler azure --list-compliance` | 2 -> `prowler azure --list-categories` |
| Kubernetes | 83 | 7 -> `prowler kubernetes --list-services` | 1 -> `prowler kubernetes --list-compliance` | 7 -> `prowler kubernetes --list-categories` |
# 💻 Installation
## Pip package
Prowler is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/), thus can be installed using pip with Python >= 3.9, < 3.13:
## Prowler App
Prowler App can be installed in different ways, depending on your environment:
> See how to use Prowler App in the [Prowler App Usage Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/).
### Docker Compose
**Requirements**
* `Docker Compose` installed: https://docs.docker.com/compose/install/.
**Commands**
``` console
curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/docker-compose.yml
curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/.env
docker compose up -d
```
> Enjoy Prowler App at http://localhost:3000 by signing up with your email and password.
### From GitHub
**Requirements**
* `git` installed.
* `poetry` installed: [poetry installation](https://python-poetry.org/docs/#installation).
* `npm` installed: [npm installation](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm).
* `Docker Compose` installed: https://docs.docker.com/compose/install/.
**Commands to run the API**
``` console
git clone https://github.com/prowler-cloud/prowler
cd prowler/api
poetry install
poetry shell
set -a
source .env
docker compose up postgres valkey -d
cd src/backend
python manage.py migrate --database admin
gunicorn -c config/guniconf.py config.wsgi:application
```
> Now, you can access the API documentation at http://localhost:8080/api/v1/docs.
**Commands to run the API Worker**
``` console
git clone https://github.com/prowler-cloud/prowler
cd prowler/api
poetry install
poetry shell
set -a
source .env
cd src/backend
python -m celery -A config.celery worker -l info -E
```
**Commands to run the UI**
``` console
git clone https://github.com/prowler-cloud/prowler
cd prowler/ui
npm install
npm run build
npm start
```
> Enjoy Prowler App at http://localhost:3000 by signing up with your email and password.
## Prowler CLI
### Pip package
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/), thus can be installed using pip with Python >= 3.9, < 3.13:
```console
pip install prowler
prowler -v
```
>More details at [https://docs.prowler.com](https://docs.prowler.com/projects/prowler-open-source/en/latest/)
>More details at [https://docs.prowler.com](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-cli-installation)
## Containers
### Containers
The available versions of Prowler are the following:
The available versions of Prowler CLI are the following:
- `latest`: in sync with `master` branch (bear in mind that it is not a stable version)
- `v4-latest`: in sync with `v4` branch (bear in mind that it is not a stable version)
- `v3-latest`: in sync with `v3` branch (bear in mind that it is not a stable version)
- `<x.y.z>` (release): you can find the releases [here](https://github.com/prowler-cloud/prowler/releases), those are stable releases.
- `stable`: this tag always point to the latest release.
- `v4-stable`: this tag always point to the latest release for v4.
- `v3-stable`: this tag always point to the latest release for v3.
The container images are available here:
- Prowler CLI:
- [DockerHub](https://hub.docker.com/r/toniblyx/prowler/tags)
- [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler)
- Prowler App:
- [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags)
- [DockerHub - Prowler API](https://hub.docker.com/r/prowlercloud/prowler-api/tags)
- [DockerHub](https://hub.docker.com/r/toniblyx/prowler/tags)
- [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler)
## From GitHub
### From GitHub
Python >= 3.9, < 3.13 is required with pip and poetry:
```
``` console
git clone https://github.com/prowler-cloud/prowler
cd prowler
poetry shell
@@ -108,6 +195,16 @@ python prowler.py -v
> If you want to clone Prowler from Windows, use `git config core.longpaths true` to allow long file paths.
# 📐✏️ High level architecture
## Prowler App
The **Prowler App** consists of three main components:
- **Prowler UI**: A user-friendly web interface for running Prowler and viewing results, powered by Next.js.
- **Prowler API**: The backend API that executes Prowler scans and stores the results, built with Django REST Framework.
- **Prowler SDK**: A Python SDK that integrates with the Prowler CLI for advanced functionality.
![Prowler App Architecture](docs/img/prowler-app-architecture.png)
## Prowler CLI
You can run Prowler from your workstation, a Kubernetes Job, a Google Compute Engine, an Azure VM, an EC2 instance, Fargate or any other container, CloudShell and many more.
![Architecture](docs/img/architecture.png)
+1
View File
@@ -21,6 +21,7 @@ DJANGO_STALE_WHILE_REVALIDATE=60
DJANGO_SECRETS_ENCRYPTION_KEY=""
# Decide whether to allow Django manage database table partitions
DJANGO_MANAGE_DB_PARTITIONS=[True|False]
DJANGO_CELERY_DEADLOCK_ATTEMPTS=5
# PostgreSQL settings
# If running django and celery on host, use 'localhost', else use 'postgres-db'
+40 -23
View File
@@ -439,13 +439,13 @@ azure-core = ">=1.26.2,<2.0.0"
[[package]]
name = "azure-mgmt-cosmosdb"
version = "9.6.0"
version = "9.7.0"
description = "Microsoft Azure Cosmos DB Management Client Library for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "azure_mgmt_cosmosdb-9.6.0-py3-none-any.whl", hash = "sha256:02b4108867de58e0b89a206ee7b7588b439e1f6fef2377ce1979b803a0d02d5a"},
{file = "azure_mgmt_cosmosdb-9.6.0.tar.gz", hash = "sha256:667c7d8a8f542b0e7972e63274af536ad985187e24a6cc2e3c8eef35560881fc"},
{file = "azure_mgmt_cosmosdb-9.7.0-py3-none-any.whl", hash = "sha256:be735a554d16995c8cefe413e62119985f8fabae1cb45a6f6ad2c3958bed14da"},
{file = "azure_mgmt_cosmosdb-9.7.0.tar.gz", hash = "sha256:b5072d319f11953d8f12e22459aded1912d5f27e442e1d8b49596a85005410a1"},
]
[package.dependencies]
@@ -537,6 +537,22 @@ azure-mgmt-core = ">=1.3.2"
isodate = ">=0.6.1"
typing-extensions = ">=4.6.0"
[[package]]
name = "azure-mgmt-search"
version = "9.1.0"
description = "Microsoft Azure Search Management Client Library for Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "azure-mgmt-search-9.1.0.tar.gz", hash = "sha256:53bc6eeadb0974d21f120bb21bb5e6827df6d650e17347460fd83e2d68883599"},
{file = "azure_mgmt_search-9.1.0-py3-none-any.whl", hash = "sha256:488ff81477e980e2b7abf0b857387c74ebbad419e6f6126044e3e6fad2da72b6"},
]
[package.dependencies]
azure-common = ">=1.1,<2.0"
azure-mgmt-core = ">=1.3.2,<2.0.0"
isodate = ">=0.6.1,<1.0.0"
[[package]]
name = "azure-mgmt-security"
version = "7.0.0"
@@ -686,17 +702,17 @@ files = [
[[package]]
name = "boto3"
version = "1.35.60"
version = "1.35.66"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.35.60-py3-none-any.whl", hash = "sha256:a34d28de1a1f6ca6ec3edd05c26db16e422293d8f9dcd94f308059a434596753"},
{file = "boto3-1.35.60.tar.gz", hash = "sha256:e573504c67c3e438fd4b0222119ed1a73b644c78eb3b6dee0b36a6c70ecf7677"},
{file = "boto3-1.35.66-py3-none-any.whl", hash = "sha256:09a610f8cf4d3c22d4ca69c1f89079e3a1c82805ce94fa0eb4ecdd4d2ba6c4bc"},
{file = "boto3-1.35.66.tar.gz", hash = "sha256:c392b9168b65e9c23483eaccb5b68d1f960232d7f967a1e00a045ba065ce050d"},
]
[package.dependencies]
botocore = ">=1.35.60,<1.36.0"
botocore = ">=1.35.66,<1.36.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@@ -705,13 +721,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.35.60"
version = "1.35.69"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.35.60-py3-none-any.whl", hash = "sha256:ddccfc39a0a55ac0321191a36d29c2ea9be2c96ceefb3928dd3c91c79c494d50"},
{file = "botocore-1.35.60.tar.gz", hash = "sha256:378f53037d817bed2c04a006b7319745e664030182211429c924647273b29bc9"},
{file = "botocore-1.35.69-py3-none-any.whl", hash = "sha256:cad8d9305f873404eee4b197d84e60a40975d43cbe1ab63abe893420ddfe6e3c"},
{file = "botocore-1.35.69.tar.gz", hash = "sha256:f9f23dd76fb247d9b0e8d411d2995e6f847fc451c026f1e58e300f815b0b36eb"},
]
[package.dependencies]
@@ -1891,13 +1907,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"]
[[package]]
name = "google-api-python-client"
version = "2.153.0"
version = "2.154.0"
description = "Google API Client Library for Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "google_api_python_client-2.153.0-py2.py3-none-any.whl", hash = "sha256:6ff13bbfa92a57972e33ec3808e18309e5981b8ca1300e5da23bf2b4d6947384"},
{file = "google_api_python_client-2.153.0.tar.gz", hash = "sha256:35cce8647f9c163fc04fb4d811fc91aae51954a2bdd74918decbe0e65d791dd2"},
{file = "google_api_python_client-2.154.0-py2.py3-none-any.whl", hash = "sha256:a521bbbb2ec0ba9d6f307cdd64ed6e21eeac372d1bd7493a4ab5022941f784ad"},
{file = "google_api_python_client-2.154.0.tar.gz", hash = "sha256:1b420062e03bfcaa1c79e2e00a612d29a6a934151ceb3d272fe150a656dc8f17"},
]
[package.dependencies]
@@ -3275,7 +3291,7 @@ files = [
[[package]]
name = "prowler"
version = "4.6.0"
version = "5.0.0"
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
optional = false
python-versions = ">=3.9,<3.13"
@@ -3292,26 +3308,27 @@ azure-mgmt-authorization = "4.0.0"
azure-mgmt-compute = "33.0.0"
azure-mgmt-containerregistry = "10.3.0"
azure-mgmt-containerservice = "33.0.0"
azure-mgmt-cosmosdb = "9.6.0"
azure-mgmt-cosmosdb = "9.7.0"
azure-mgmt-keyvault = "10.3.1"
azure-mgmt-monitor = "6.0.2"
azure-mgmt-network = "28.0.0"
azure-mgmt-rdbms = "10.1.0"
azure-mgmt-resource = "23.2.0"
azure-mgmt-search = "9.1.0"
azure-mgmt-security = "7.0.0"
azure-mgmt-sql = "3.0.1"
azure-mgmt-storage = "21.2.1"
azure-mgmt-subscription = "3.1.1"
azure-mgmt-web = "7.3.1"
azure-storage-blob = "12.24.0"
boto3 = "1.35.60"
botocore = "1.35.60"
boto3 = "1.35.66"
botocore = "1.35.69"
colorama = "0.4.6"
cryptography = "43.0.1"
dash = "2.18.2"
dash-bootstrap-components = "1.6.0"
detect-secrets = "1.5.0"
google-api-python-client = "2.153.0"
google-api-python-client = "2.154.0"
google-auth-httplib2 = ">=0.1,<0.3"
jsonschema = "4.23.0"
kubernetes = "31.0.0"
@@ -3325,7 +3342,7 @@ python-dateutil = "^2.9.0.post0"
pytz = "2024.2"
schema = "0.7.7"
shodan = "1.31.0"
slack-sdk = "3.33.3"
slack-sdk = "3.33.4"
tabulate = "0.9.0"
tzlocal = "5.2"
@@ -3333,7 +3350,7 @@ tzlocal = "5.2"
type = "git"
url = "https://github.com/prowler-cloud/prowler.git"
reference = "master"
resolved_reference = "8be83fc632445cd25eeb90ed20257716b673cead"
resolved_reference = "9c383baff309d868b37934b694df9aacba397fad"
[[package]]
name = "psutil"
@@ -4458,13 +4475,13 @@ files = [
[[package]]
name = "slack-sdk"
version = "3.33.3"
version = "3.33.4"
description = "The Slack API Platform SDK for Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "slack_sdk-3.33.3-py2.py3-none-any.whl", hash = "sha256:0515fb93cd03b18de61f876a8304c4c3cef4dd3c2a3bad62d7394d2eb5a3c8e6"},
{file = "slack_sdk-3.33.3.tar.gz", hash = "sha256:4cc44c9ffe4bb28a01fbe3264c2f466c783b893a4eca62026ab845ec7c176ff1"},
{file = "slack_sdk-3.33.4-py2.py3-none-any.whl", hash = "sha256:9f30cb3c9c07b441c49d53fc27f9f1837ad1592a7e9d4ca431f53cdad8826cc6"},
{file = "slack_sdk-3.33.4.tar.gz", hash = "sha256:5e109847f6b6a22d227609226ba4ed936109dc00675bddeb7e0bee502d3ee7e0"},
]
[package.extras]
+33 -3
View File
@@ -1,12 +1,13 @@
import secrets
from contextlib import contextmanager
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from django.conf import settings
from django.contrib.auth.models import BaseUserManager
from django.db import models, transaction, connection
from django.core.paginator import Paginator
from django.db import connection, models, transaction
from psycopg2 import connect as psycopg2_connect
from psycopg2.extensions import new_type, register_type, register_adapter, AsIs
from psycopg2.extensions import AsIs, new_type, register_adapter, register_type
DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test"
DB_PASSWORD = (
@@ -88,6 +89,35 @@ def generate_random_token(length: int = 14, symbols: str | None = None) -> str:
return "".join(secrets.choice(symbols or _symbols) for _ in range(length))
def batch_delete(queryset, batch_size=5000):
"""
Deletes objects in batches and returns the total number of deletions and a summary.
Args:
queryset (QuerySet): The queryset of objects to delete.
batch_size (int): The number of objects to delete in each batch.
Returns:
tuple: (total_deleted, deletion_summary)
"""
total_deleted = 0
deletion_summary = {}
paginator = Paginator(queryset.order_by("id").only("id"), batch_size)
for page_num in paginator.page_range:
batch_ids = [obj.id for obj in paginator.page(page_num).object_list]
deleted_count, deleted_info = queryset.filter(id__in=batch_ids).delete()
total_deleted += deleted_count
for model_label, count in deleted_info.items():
deletion_summary[model_label] = deletion_summary.get(model_label, 0) + count
return total_deleted, deletion_summary
# Postgres Enums
+54 -15
View File
@@ -4,47 +4,48 @@ from django.conf import settings
from django.db.models import Q
from django_filters.rest_framework import (
BaseInFilter,
FilterSet,
BooleanFilter,
CharFilter,
UUIDFilter,
DateFilter,
ChoiceFilter,
DateFilter,
FilterSet,
UUIDFilter,
)
from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
from rest_framework_json_api.serializers import ValidationError
from api.db_utils import (
ProviderEnumField,
FindingDeltaEnumField,
StatusEnumField,
SeverityEnumField,
InvitationStateEnumField,
ProviderEnumField,
SeverityEnumField,
StatusEnumField,
)
from api.models import (
User,
ComplianceOverview,
Finding,
Invitation,
Membership,
Provider,
ProviderGroup,
ProviderSecret,
Resource,
ResourceTag,
Scan,
Task,
StateChoices,
Finding,
ScanSummary,
SeverityChoices,
StateChoices,
StatusChoices,
ProviderSecret,
Invitation,
ComplianceOverview,
Task,
User,
)
from api.rls import Tenant
from api.uuid_utils import (
datetime_to_uuid7,
uuid7_start,
transform_into_uuid7,
uuid7_end,
uuid7_range,
transform_into_uuid7,
uuid7_start,
)
from api.v1.serializers import TaskBase
@@ -57,6 +58,13 @@ class CustomDjangoFilterBackend(DjangoFilterBackend):
"""
return None
def get_filterset_class(self, view, queryset=None):
# Check if the view has 'get_filterset_class' method
if hasattr(view, "get_filterset_class"):
return view.get_filterset_class()
# Fallback to the default implementation
return super().get_filterset_class(view, queryset)
class UUIDInFilter(BaseInFilter, UUIDFilter):
pass
@@ -156,7 +164,12 @@ class ScanFilter(ProviderRelationshipFilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
completed_at = DateFilter(field_name="completed_at", lookup_expr="date")
started_at = DateFilter(field_name="started_at", lookup_expr="date")
next_scan_at = DateFilter(field_name="next_scan_at", lookup_expr="date")
trigger = ChoiceFilter(choices=Scan.TriggerChoices.choices)
state = ChoiceFilter(choices=StateChoices.choices)
state__in = ChoiceInFilter(
field_name="state", choices=StateChoices.choices, lookup_expr="in"
)
class Meta:
model = Scan
@@ -164,6 +177,7 @@ class ScanFilter(ProviderRelationshipFilterSet):
"provider": ["exact", "in"],
"name": ["exact", "icontains"],
"started_at": ["gte", "lte"],
"next_scan_at": ["gte", "lte"],
"trigger": ["exact"],
}
@@ -482,3 +496,28 @@ class ComplianceOverviewFilter(FilterSet):
"version": ["exact", "icontains"],
"region": ["exact", "icontains", "in"],
}
class ScanSummaryFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
provider_type = ChoiceFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
)
provider_type__in = ChoiceInFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
)
region = CharFilter(field_name="region")
muted_findings = BooleanFilter(method="filter_muted_findings")
def filter_muted_findings(self, queryset, name, value):
if not value:
return queryset.exclude(muted__gt=0)
return queryset
class Meta:
model = ScanSummary
fields = {
"inserted_at": ["date", "gte", "lte"],
"region": ["exact", "icontains", "in"],
}
+102 -21
View File
@@ -22,36 +22,36 @@ from uuid6 import uuid7
import api.rls
from api.db_utils import (
PostgresEnumMigration,
MemberRoleEnumField,
DB_PROWLER_PASSWORD,
DB_PROWLER_USER,
POSTGRES_TENANT_VAR,
POSTGRES_USER_VAR,
TASK_RUNNER_DB_TABLE,
InvitationStateEnum,
InvitationStateEnumField,
MemberRoleEnum,
MemberRoleEnumField,
PostgresEnumMigration,
ProviderEnum,
ProviderEnumField,
ProviderSecretTypeEnum,
ProviderSecretTypeEnumField,
ScanTriggerEnum,
StateEnumField,
StateEnum,
ScanTriggerEnumField,
InvitationStateEnum,
InvitationStateEnumField,
StateEnum,
StateEnumField,
register_enum,
DB_PROWLER_USER,
DB_PROWLER_PASSWORD,
TASK_RUNNER_DB_TABLE,
POSTGRES_TENANT_VAR,
POSTGRES_USER_VAR,
)
from api.models import (
Provider,
Scan,
StateChoices,
Finding,
StatusChoices,
SeverityChoices,
Membership,
ProviderSecret,
Invitation,
Membership,
Provider,
ProviderSecret,
Scan,
SeverityChoices,
StateChoices,
StatusChoices,
)
DB_NAME = settings.DATABASES["default"]["NAME"]
@@ -289,7 +289,8 @@ class Migration(migrations.Migration):
),
),
# Enable tenants RLS based on memberships
migrations.RunSQL(f"""
migrations.RunSQL(
f"""
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
-- Policy for SELECT
@@ -364,7 +365,8 @@ class Migration(migrations.Migration):
FOR INSERT
TO {DB_PROWLER_USER}
WITH CHECK (true);
"""),
"""
),
# Create and register ProviderEnum type
migrations.RunPython(
ProviderEnumMigration.create_enum_type,
@@ -385,6 +387,7 @@ class Migration(migrations.Migration):
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("is_deleted", models.BooleanField(default=False)),
(
"provider",
ProviderEnumField(
@@ -676,6 +679,7 @@ class Migration(migrations.Migration):
("updated_at", models.DateTimeField(auto_now=True)),
("started_at", models.DateTimeField(null=True, blank=True)),
("completed_at", models.DateTimeField(null=True, blank=True)),
("next_scan_at", models.DateTimeField(null=True, blank=True)),
(
"provider",
models.ForeignKey(
@@ -1091,7 +1095,7 @@ class Migration(migrations.Migration):
},
bases=(PostgresPartitionedModel,),
managers=[
("objects", PostgresManager()),
("objects", api.models.ActiveProviderPartitionedManager()),
],
),
migrations.RunSQL(
@@ -1482,4 +1486,81 @@ class Migration(migrations.Migration):
name="comp_ov_cp_id_req_fail_idx",
),
),
migrations.CreateModel(
name="ScanSummary",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("check_id", models.CharField(max_length=100)),
("service", models.TextField()),
(
"severity",
api.db_utils.SeverityEnumField(
choices=[
("critical", "Critical"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("informational", "Informational"),
]
),
),
("region", models.TextField()),
("_pass", models.IntegerField(db_column="pass", default=0)),
("fail", models.IntegerField(default=0)),
("muted", models.IntegerField(default=0)),
("total", models.IntegerField(default=0)),
("new", models.IntegerField(default=0)),
("changed", models.IntegerField(default=0)),
("unchanged", models.IntegerField(default=0)),
("fail_new", models.IntegerField(default=0)),
("fail_changed", models.IntegerField(default=0)),
("pass_new", models.IntegerField(default=0)),
("pass_changed", models.IntegerField(default=0)),
("muted_new", models.IntegerField(default=0)),
("muted_changed", models.IntegerField(default=0)),
(
"scan",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="aggregations",
related_query_name="aggregation",
to="api.scan",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "scan_summaries",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="scansummary",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_scansummary",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="scansummary",
constraint=models.UniqueConstraint(
fields=("tenant", "scan", "check_id", "service", "severity", "region"),
name="unique_scan_summary",
),
),
]
+116 -20
View File
@@ -1,6 +1,6 @@
import json
import re
from uuid import uuid4, UUID
from uuid import UUID, uuid4
from cryptography.fernet import Fernet
from django.conf import settings
@@ -9,37 +9,37 @@ from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
from psqlextra.manager import PostgresManager
from psqlextra.models import PostgresPartitionedModel
from psqlextra.types import PostgresPartitioningMethod
from uuid6 import uuid7
from api.db_utils import (
MemberRoleEnumField,
enum_to_choices,
ProviderEnumField,
StateEnumField,
ScanTriggerEnumField,
FindingDeltaEnumField,
SeverityEnumField,
StatusEnumField,
CustomUserManager,
ProviderSecretTypeEnumField,
FindingDeltaEnumField,
InvitationStateEnumField,
one_week_from_now,
MemberRoleEnumField,
ProviderEnumField,
ProviderSecretTypeEnumField,
ScanTriggerEnumField,
SeverityEnumField,
StateEnumField,
StatusEnumField,
enum_to_choices,
generate_random_token,
one_week_from_now,
)
from api.exceptions import ModelValidationError
from api.rls import (
RowLevelSecurityProtectedModel,
)
from api.rls import (
Tenant,
RowLevelSecurityConstraint,
BaseSecurityConstraint,
RowLevelSecurityConstraint,
RowLevelSecurityProtectedModel,
Tenant,
)
from prowler.lib.check.models import Severity
fernet = Fernet(settings.SECRETS_ENCRYPTION_KEY.encode())
@@ -69,6 +69,24 @@ class StateChoices(models.TextChoices):
CANCELLED = "cancelled", _("Cancelled")
class ActiveProviderManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(self.active_provider_filter())
def active_provider_filter(self):
if self.model is Provider:
return Q(is_deleted=False)
elif self.model in [Finding, ComplianceOverview, ScanSummary]:
return Q(scan__provider__is_deleted=False)
else:
return Q(provider__is_deleted=False)
class ActiveProviderPartitionedManager(PostgresManager, ActiveProviderManager):
def get_queryset(self):
return super().get_queryset().filter(self.active_provider_filter())
class User(AbstractBaseUser):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(max_length=150, validators=[MinLengthValidator(3)])
@@ -149,6 +167,9 @@ class Membership(models.Model):
class Provider(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
class ProviderChoices(models.TextChoices):
AWS = "aws", _("AWS")
AZURE = "azure", _("Azure")
@@ -189,10 +210,14 @@ class Provider(RowLevelSecurityProtectedModel):
@staticmethod
def validate_kubernetes_uid(value):
if not re.match(r"^[a-z0-9]([-a-z0-9]{1,61}[a-z0-9])?$", value):
if not re.match(
r"(^[a-z0-9]([-a-z0-9]{1,61}[a-z0-9])?$)|(^arn:aws(-cn|-us-gov|-iso|-iso-b)?:[a-zA-Z0-9\-]+:([a-z]{2}-[a-z]+-\d{1})?:(\d{12})?:[a-zA-Z0-9\-_\/:\.\*]+(:\d+)?$)",
value,
):
raise ModelValidationError(
detail="K8s provider ID must be up to 63 characters, start and end with a lowercase letter or number, "
"and contain only lowercase alphanumeric characters and hyphens.",
detail="The value must either be a valid Kubernetes UID (up to 63 characters, "
"starting and ending with a lowercase letter or number, containing only "
"lowercase alphanumeric characters and hyphens) or a valid EKS ARN.",
code="kubernetes-uid",
pointer="/data/attributes/uid",
)
@@ -200,6 +225,7 @@ class Provider(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
is_deleted = models.BooleanField(default=False)
provider = ProviderEnumField(
choices=ProviderChoices.choices, default=ProviderChoices.AWS
)
@@ -272,6 +298,9 @@ class ProviderGroup(RowLevelSecurityProtectedModel):
class ProviderGroupMembership(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
provider = models.ForeignKey(
Provider,
@@ -336,6 +365,9 @@ class Task(RowLevelSecurityProtectedModel):
class Scan(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
class TriggerChoices(models.TextChoices):
SCHEDULED = "scheduled", _("Scheduled")
MANUAL = "manual", _("Manual")
@@ -371,6 +403,7 @@ class Scan(RowLevelSecurityProtectedModel):
updated_at = models.DateTimeField(auto_now=True, editable=False)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
next_scan_at = models.DateTimeField(null=True, blank=True)
# TODO: mutelist foreign key
class Meta(RowLevelSecurityProtectedModel.Meta):
@@ -433,6 +466,9 @@ class ResourceTag(RowLevelSecurityProtectedModel):
class Resource(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
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)
@@ -559,6 +595,9 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
Note when creating migrations, you must use `python manage.py pgmakemigrations` to create the migrations.
"""
objects = ActiveProviderPartitionedManager()
all_objects = models.Manager()
class PartitioningMeta:
method = PostgresPartitioningMethod.RANGE
key = ["id"]
@@ -710,6 +749,9 @@ class ResourceFindingMapping(PostgresPartitionedModel, RowLevelSecurityProtected
class ProviderSecret(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
class TypeChoices(models.TextChoices):
STATIC = "static", _("Key-value pairs")
ROLE = "role", _("Role assumption")
@@ -810,6 +852,9 @@ class Invitation(RowLevelSecurityProtectedModel):
class ComplianceOverview(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
compliance_id = models.CharField(max_length=100, blank=False, null=False)
@@ -856,3 +901,54 @@ class ComplianceOverview(RowLevelSecurityProtectedModel):
class JSONAPIMeta:
resource_name = "compliance-overviews"
class ScanSummary(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
check_id = models.CharField(max_length=100, blank=False, null=False)
service = models.TextField(blank=False)
severity = SeverityEnumField(choices=SeverityChoices)
region = models.TextField(blank=False)
_pass = models.IntegerField(db_column="pass", default=0)
fail = models.IntegerField(default=0)
muted = models.IntegerField(default=0)
total = models.IntegerField(default=0)
new = models.IntegerField(default=0)
changed = models.IntegerField(default=0)
unchanged = models.IntegerField(default=0)
fail_new = models.IntegerField(default=0)
fail_changed = models.IntegerField(default=0)
pass_new = models.IntegerField(default=0)
pass_changed = models.IntegerField(default=0)
muted_new = models.IntegerField(default=0)
muted_changed = models.IntegerField(default=0)
scan = models.ForeignKey(
Scan,
on_delete=models.CASCADE,
related_name="aggregations",
related_query_name="aggregation",
)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "scan_summaries"
constraints = [
models.UniqueConstraint(
fields=("tenant", "scan", "check_id", "service", "severity", "region"),
name="unique_scan_summary",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class JSONAPIMeta:
resource_name = "scan-summaries"
File diff suppressed because it is too large Load Diff
+160 -14
View File
@@ -1,29 +1,25 @@
import json
from datetime import datetime
from datetime import timezone, timedelta
from datetime import datetime, timedelta, timezone
from unittest.mock import ANY, Mock, patch
import jwt
import pytest
from conftest import API_JSON_CONTENT_TYPE, TEST_PASSWORD, TEST_USER
from django.urls import reverse
from rest_framework import status
from api.models import (
User,
Invitation,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
ProviderSecret,
Invitation,
Scan,
StateChoices,
User,
)
from api.rls import Tenant
from conftest import (
API_JSON_CONTENT_TYPE,
TEST_PASSWORD,
TEST_USER,
)
TODAY = str(datetime.today().date())
@@ -788,11 +784,50 @@ class TestMembershipViewSet:
@pytest.mark.django_db
class TestProviderViewSet:
@pytest.fixture(scope="function")
def create_provider_group_relationship(
self, tenants_fixture, providers_fixture, provider_groups_fixture
):
tenant, *_ = tenants_fixture
provider1, *_ = providers_fixture
provider_group1, *_ = provider_groups_fixture
provider_group_membership = ProviderGroupMembership.objects.create(
tenant=tenant, provider=provider1, provider_group=provider_group1
)
return provider_group_membership
def test_providers_list(self, authenticated_client, providers_fixture):
response = authenticated_client.get(reverse("provider-list"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(providers_fixture)
@pytest.mark.parametrize(
"include_values, expected_resources",
[
("provider_groups", ["provider-groups"]),
],
)
def test_providers_list_include(
self,
include_values,
expected_resources,
authenticated_client,
providers_fixture,
create_provider_group_relationship,
):
response = authenticated_client.get(
reverse("provider-list"), {"include": include_values}
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(providers_fixture)
assert "included" in response.json()
included_data = response.json()["included"]
for expected_type in expected_resources:
assert any(
d.get("type") == expected_type for d in included_data
), f"Expected type '{expected_type}' not found in included data"
def test_providers_retrieve(self, authenticated_client, providers_fixture):
provider1, *_ = providers_fixture
response = authenticated_client.get(
@@ -1794,7 +1829,7 @@ class TestScanViewSet:
],
)
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.perform_scan_task.delay")
@patch("api.v1.views.perform_scan_task.apply_async")
def test_scans_create_valid(
self,
mock_perform_scan_task,
@@ -1938,6 +1973,10 @@ class TestScanViewSet:
("started_at.gte", "2024-01-01", 3),
("started_at.lte", "2024-01-01", 0),
("trigger", Scan.TriggerChoices.MANUAL, 1),
("state", StateChoices.AVAILABLE, 2),
("state", StateChoices.FAILED, 1),
("state.in", f"{StateChoices.FAILED},{StateChoices.AVAILABLE}", 3),
("trigger", Scan.TriggerChoices.MANUAL, 1),
]
),
)
@@ -2455,6 +2494,72 @@ class TestFindingViewSet:
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_findings_services_regions_retrieve(
self, authenticated_client, findings_fixture
):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{"filter[inserted_at]": finding_1.updated_at.strftime("%Y-%m-%d")},
)
data = response.json()
expected_services = {"ec2", "s3"}
expected_regions = {"eu-west-1", "us-east-1"}
assert data["data"]["type"] == "finding-dynamic-filters"
assert data["data"]["id"] is None
assert set(data["data"]["attributes"]["services"]) == expected_services
assert set(data["data"]["attributes"]["regions"]) == expected_regions
def test_findings_services_regions_severity_retrieve(
self, authenticated_client, findings_fixture
):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{
"filter[severity__in]": ["low", "medium"],
"filter[inserted_at]": finding_1.updated_at.strftime("%Y-%m-%d"),
},
)
data = response.json()
expected_services = {"s3"}
expected_regions = {"eu-west-1"}
assert data["data"]["type"] == "finding-dynamic-filters"
assert data["data"]["id"] is None
assert set(data["data"]["attributes"]["services"]) == expected_services
assert set(data["data"]["attributes"]["regions"]) == expected_regions
def test_findings_services_regions_future_date(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{"filter[inserted_at]": "2048-01-01"},
)
data = response.json()
assert data["data"]["type"] == "finding-dynamic-filters"
assert data["data"]["id"] is None
assert data["data"]["attributes"]["services"] == []
assert data["data"]["attributes"]["regions"] == []
def test_findings_services_regions_invalid_date(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{"filter[inserted_at]": "2048-01-011"},
)
assert response.json() == {
"errors": [
{
"detail": "Enter a valid date.",
"status": "400",
"source": {"pointer": "/data/attributes/inserted_at"},
"code": "invalid",
}
]
}
@pytest.mark.django_db
class TestJWTFields:
@@ -3009,9 +3114,9 @@ class TestInvitationViewSet:
response = authenticated_client.get(
reverse("invitation-list"),
{
f"filter[{filter_name}]": filter_value
if filter_name != "inviter"
else str(user.id)
f"filter[{filter_name}]": (
filter_value if filter_name != "inviter" else str(user.id)
)
},
)
@@ -3262,3 +3367,44 @@ class TestOverviewViewSet:
assert response.json()["data"][0]["attributes"]["resources"]["total"] == len(
resources_fixture
)
# TODO Add more tests for the rest of overviews
@pytest.mark.django_db
class TestScheduleViewSet:
@pytest.mark.parametrize("method", ["get", "post"])
def test_schedule_invalid_method_list(self, method, authenticated_client):
response = getattr(authenticated_client, method)(reverse("schedule-list"))
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.schedule_provider_scan")
def test_schedule_daily(
self,
mock_schedule_scan,
mock_task_get,
authenticated_client,
providers_fixture,
tasks_fixture,
):
provider, *_ = providers_fixture
prowler_task = tasks_fixture[0]
mock_schedule_scan.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
json_payload = {
"provider_id": str(provider.id),
}
response = authenticated_client.post(
reverse("schedule-daily"), data=json_payload, format="json"
)
assert response.status_code == status.HTTP_202_ACCEPTED
def test_schedule_daily_provider_does_not_exist(self, authenticated_client):
json_payload = {
"provider_id": "4846c2f9-84b2-442b-94dd-3082e8eb9584",
}
response = authenticated_client.post(
reverse("schedule-daily"), data=json_payload, format="json"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
+92 -11
View File
@@ -1,5 +1,5 @@
import json
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from django.conf import settings
from django.contrib.auth import authenticate
@@ -14,24 +14,23 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from api.models import (
StateChoices,
User,
ComplianceOverview,
Finding,
Invitation,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
Task,
ProviderSecret,
Resource,
ResourceTag,
Finding,
ProviderSecret,
Invitation,
ComplianceOverview,
Scan,
StateChoices,
Task,
User,
)
from api.rls import Tenant
# Tokens
@@ -390,6 +389,12 @@ class ProviderGroupSerializer(RLSSerializer, BaseWriteSerializer):
}
class ProviderGroupIncludedSerializer(RLSSerializer, BaseWriteSerializer):
class Meta:
model = ProviderGroup
fields = ["id", "name"]
class ProviderGroupUpdateSerializer(RLSSerializer, BaseWriteSerializer):
"""
Serializer for updating the ProviderGroup model.
@@ -453,6 +458,10 @@ class ProviderSerializer(RLSSerializer):
provider = ProviderEnumSerializerField()
connection = serializers.SerializerMethodField(read_only=True)
included_serializers = {
"provider_groups": "api.v1.serializers.ProviderGroupIncludedSerializer",
}
class Meta:
model = Provider
fields = [
@@ -465,6 +474,7 @@ class ProviderSerializer(RLSSerializer):
"connection",
# "scanner_args",
"secret",
"provider_groups",
"url",
]
@@ -537,9 +547,11 @@ class ScanSerializer(RLSSerializer):
"duration",
"provider",
"task",
"inserted_at",
"started_at",
"completed_at",
"scheduled_at",
"next_scan_at",
"url",
]
@@ -708,6 +720,14 @@ class FindingSerializer(RLSSerializer):
}
class FindingDynamicFilterSerializer(serializers.Serializer):
services = serializers.ListField(child=serializers.CharField(), allow_empty=True)
regions = serializers.ListField(child=serializers.CharField(), allow_empty=True)
class Meta:
resource_name = "finding-dynamic-filters"
# Provider secrets
class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
@staticmethod
@@ -1234,7 +1254,7 @@ class OverviewProviderSerializer(serializers.Serializer):
resources = serializers.SerializerMethodField(read_only=True)
class JSONAPIMeta:
resource_name = "provider-overviews"
resource_name = "providers-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
@@ -1270,3 +1290,64 @@ class OverviewProviderSerializer(serializers.Serializer):
return {
"total": obj["total_resources"],
}
class OverviewFindingSerializer(serializers.Serializer):
id = serializers.CharField(default="n/a")
new = serializers.IntegerField()
changed = serializers.IntegerField()
unchanged = serializers.IntegerField()
fail_new = serializers.IntegerField()
fail_changed = serializers.IntegerField()
pass_new = serializers.IntegerField()
pass_changed = serializers.IntegerField()
muted_new = serializers.IntegerField()
muted_changed = serializers.IntegerField()
total = serializers.IntegerField()
_pass = serializers.IntegerField()
fail = serializers.IntegerField()
muted = serializers.IntegerField()
class JSONAPIMeta:
resource_name = "findings-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["pass"] = self.fields.pop("_pass")
class OverviewSeveritySerializer(serializers.Serializer):
id = serializers.CharField(default="n/a")
critical = serializers.IntegerField()
high = serializers.IntegerField()
medium = serializers.IntegerField()
low = serializers.IntegerField()
informational = serializers.IntegerField()
class JSONAPIMeta:
resource_name = "findings-severity-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
# Schedules
class ScheduleDailyCreateSerializer(serializers.Serializer):
provider_id = serializers.UUIDField(required=True)
class JSONAPIMeta:
resource_name = "daily-schedules"
# TODO: DRY this when we have more time
def validate(self, data):
if hasattr(self, "initial_data"):
initial_data = set(self.initial_data.keys()) - {"id", "type"}
unknown_keys = initial_data - set(self.fields.keys())
if unknown_keys:
raise ValidationError(f"Invalid fields: {unknown_keys}")
return data
+16 -14
View File
@@ -1,26 +1,27 @@
from django.urls import path, include
from django.urls import include, path
from drf_spectacular.views import SpectacularRedocView
from rest_framework_nested import routers
from api.v1.views import (
ComplianceOverviewViewSet,
CustomTokenObtainView,
CustomTokenRefreshView,
SchemaView,
UserViewSet,
TenantViewSet,
TenantMembersViewSet,
MembershipViewSet,
ProviderViewSet,
ScanViewSet,
TaskViewSet,
ResourceViewSet,
FindingViewSet,
InvitationAcceptViewSet,
InvitationViewSet,
MembershipViewSet,
OverviewViewSet,
ProviderGroupViewSet,
ProviderSecretViewSet,
InvitationViewSet,
InvitationAcceptViewSet,
OverviewViewSet,
ComplianceOverviewViewSet,
ProviderViewSet,
ResourceViewSet,
ScanViewSet,
ScheduleViewSet,
SchemaView,
TaskViewSet,
TenantMembersViewSet,
TenantViewSet,
UserViewSet,
)
router = routers.DefaultRouter(trailing_slash=False)
@@ -37,6 +38,7 @@ router.register(
r"compliance-overviews", ComplianceOverviewViewSet, basename="complianceoverview"
)
router.register(r"overviews", OverviewViewSet, basename="overview")
router.register(r"schedules", ScheduleViewSet, basename="schedule")
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
+290 -76
View File
@@ -1,21 +1,22 @@
from celery.result import AsyncResult
from django.conf import settings as django_settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import SearchQuery
from django.db import transaction
from django.db.models import Prefetch, Subquery, OuterRef, Count, Q, F
from django.db.models import Count, F, OuterRef, Prefetch, Q, Subquery, Sum
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.utils import (
extend_schema,
extend_schema_view,
OpenApiParameter,
OpenApiResponse,
OpenApiTypes,
extend_schema,
extend_schema_view,
)
from drf_spectacular.views import SpectacularAPIView
from rest_framework import status, permissions
from rest_framework import permissions, status
from rest_framework.decorators import action
from rest_framework.exceptions import (
MethodNotAllowed,
@@ -23,82 +24,90 @@ from rest_framework.exceptions import (
PermissionDenied,
ValidationError,
)
from rest_framework.generics import get_object_or_404, GenericAPIView
from rest_framework.generics import GenericAPIView, get_object_or_404
from rest_framework_json_api.views import Response
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from tasks.beat import schedule_provider_scan
from tasks.tasks import (
check_provider_connection_task,
delete_provider_task,
perform_scan_summary_task,
perform_scan_task,
)
from api.base_views import BaseTenantViewset, BaseRLSViewSet, BaseUserViewset
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
from api.db_router import MainRouter
from api.filters import (
ComplianceOverviewFilter,
FindingFilter,
InvitationFilter,
MembershipFilter,
ProviderFilter,
ProviderGroupFilter,
TenantFilter,
MembershipFilter,
ScanFilter,
TaskFilter,
ResourceFilter,
FindingFilter,
ProviderSecretFilter,
InvitationFilter,
ResourceFilter,
ScanFilter,
ScanSummaryFilter,
TaskFilter,
TenantFilter,
UserFilter,
ComplianceOverviewFilter,
)
from api.models import (
StatusChoices,
User,
ComplianceOverview,
Finding,
Invitation,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
Task,
Resource,
Finding,
ProviderSecret,
Invitation,
ComplianceOverview,
Resource,
Scan,
ScanSummary,
SeverityChoices,
StateChoices,
StatusChoices,
Task,
User,
)
from api.pagination import ComplianceOverviewPagination
from api.rls import Tenant
from api.utils import validate_invitation
from api.uuid_utils import datetime_to_uuid7
from api.v1.serializers import (
TokenSerializer,
TokenRefreshSerializer,
UserSerializer,
UserCreateSerializer,
UserUpdateSerializer,
ComplianceOverviewFullSerializer,
ComplianceOverviewSerializer,
FindingDynamicFilterSerializer,
FindingSerializer,
InvitationAcceptSerializer,
InvitationCreateSerializer,
InvitationSerializer,
InvitationUpdateSerializer,
MembershipSerializer,
OverviewFindingSerializer,
OverviewProviderSerializer,
OverviewSeveritySerializer,
ProviderCreateSerializer,
ProviderGroupMembershipUpdateSerializer,
ProviderGroupSerializer,
ProviderGroupUpdateSerializer,
ProviderGroupMembershipUpdateSerializer,
ProviderSerializer,
ProviderCreateSerializer,
ProviderUpdateSerializer,
TenantSerializer,
TaskSerializer,
ScanSerializer,
ScanCreateSerializer,
ScanUpdateSerializer,
ResourceSerializer,
FindingSerializer,
ProviderSecretCreateSerializer,
ProviderSecretSerializer,
ProviderSecretUpdateSerializer,
ProviderSecretCreateSerializer,
InvitationSerializer,
InvitationCreateSerializer,
InvitationUpdateSerializer,
InvitationAcceptSerializer,
ComplianceOverviewSerializer,
ComplianceOverviewFullSerializer,
OverviewProviderSerializer,
)
from tasks.beat import schedule_provider_scan
from tasks.tasks import (
check_provider_connection_task,
delete_provider_task,
perform_scan_task,
ProviderSerializer,
ProviderUpdateSerializer,
ResourceSerializer,
ScanCreateSerializer,
ScanSerializer,
ScanUpdateSerializer,
ScheduleDailyCreateSerializer,
TaskSerializer,
TenantSerializer,
TokenRefreshSerializer,
TokenSerializer,
UserCreateSerializer,
UserSerializer,
UserUpdateSerializer,
)
CACHE_DECORATOR = cache_control(
@@ -697,7 +706,10 @@ class ProviderViewSet(BaseRLSViewSet):
)
def destroy(self, request, *args, pk=None, **kwargs):
get_object_or_404(Provider, pk=pk)
provider = get_object_or_404(Provider, pk=pk)
provider.is_deleted = True
provider.save()
with transaction.atomic():
task = delete_provider_task.delay(
provider_id=pk, tenant_id=request.tenant_id
@@ -714,14 +726,6 @@ class ProviderViewSet(BaseRLSViewSet):
},
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
provider = serializer.save()
# Schedule a daily scan for the new provider
schedule_provider_scan(provider)
return Response(data=serializer.data, status=status.HTTP_201_CREATED)
@extend_schema_view(
list=extend_schema(
@@ -803,12 +807,18 @@ class ScanViewSet(BaseRLSViewSet):
with transaction.atomic():
scan = input_serializer.save()
with transaction.atomic():
task = perform_scan_task.delay(
tenant_id=request.tenant_id,
scan_id=str(scan.id),
provider_id=str(scan.provider_id),
# Disabled for now
# checks_to_execute=scan.scanner_args.get("checks_to_execute"),
task = perform_scan_task.apply_async(
kwargs={
"tenant_id": request.tenant_id,
"scan_id": str(scan.id),
"provider_id": str(scan.provider_id),
# Disabled for now
# checks_to_execute=scan.scanner_args.get("checks_to_execute"),
},
link=perform_scan_summary_task.si(
tenant_id=request.tenant_id,
scan_id=str(scan.id),
),
)
scan.task_id = task.id
@@ -964,6 +974,13 @@ class ResourceViewSet(BaseRLSViewSet):
summary="Retrieve data from a specific finding",
description="Fetch detailed information about a specific finding by its ID.",
),
findings_services_regions=extend_schema(
tags=["Finding"],
summary="Retrieve the services and regions that are impacted by findings",
description="Fetch services and regions affected in findings.",
responses={201: OpenApiResponse(response=MembershipSerializer)},
filters=True,
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
@@ -994,6 +1011,12 @@ class FindingViewSet(BaseRLSViewSet):
return None
return datetime_to_uuid7(inserted_at)
def get_serializer_class(self):
if self.action == "findings_services_regions":
return FindingDynamicFilterSerializer
return super().get_serializer_class()
def get_queryset(self):
queryset = Finding.objects.all()
search_value = self.request.query_params.get("filter[search]", None)
@@ -1025,6 +1048,27 @@ class FindingViewSet(BaseRLSViewSet):
return queryset
@action(detail=False, methods=["get"], url_name="findings_services_regions")
def findings_services_regions(self, request):
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
result = filtered_queryset.aggregate(
services=ArrayAgg("resources__service", flat=True, distinct=True),
regions=ArrayAgg("resources__region", flat=True, distinct=True),
)
if result["services"] is None:
result["services"] = []
if result["regions"] is None:
result["regions"] = []
serializer = self.get_serializer(
data=result,
)
serializer.is_valid(raise_exception=True)
return Response(data=serializer.data, status=status.HTTP_200_OK)
@extend_schema_view(
list=extend_schema(
@@ -1295,26 +1339,67 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
@extend_schema(tags=["Overview"])
@extend_schema_view(
providers=extend_schema(
summary="List aggregated overview data for providers",
description="Fetch aggregated summaries of the latest findings and resources for each provider. "
"This includes counts of passed, failed, and manual findings, as well as the total number "
"of resources managed by each provider.",
summary="Get aggregated provider data",
description=(
"Retrieve an aggregated overview of findings and resources grouped by providers. "
"The response includes the count of passed, failed, and manual findings, along with "
"the total number of resources managed by each provider. Only the latest findings for "
"each provider are considered in the aggregation to ensure accurate and up-to-date insights."
),
),
findings=extend_schema(
summary="Get aggregated findings data",
description=(
"Fetch aggregated findings data across all providers, grouped by various metrics such as "
"passed, failed, muted, and total findings. This endpoint calculates summary statistics "
"based on the latest scans for each provider and applies any provided filters, such as "
"region, provider type, and scan date."
),
filters=True,
),
findings_severity=extend_schema(
summary="Get findings data by severity",
description=(
"Retrieve an aggregated summary of findings grouped by severity levels, such as low, medium, "
"high, and critical. The response includes the total count of findings for each severity, "
"considering only the latest scans for each provider. Additional filters can be applied to "
"narrow down results by region, provider type, or other attributes."
),
filters=True,
),
)
@method_decorator(CACHE_DECORATOR, name="list")
class OverviewViewSet(BaseRLSViewSet):
queryset = ComplianceOverview.objects.all()
http_method_names = ["get"]
ordering = ["compliance_id"]
ordering = ["-id"]
def get_queryset(self):
return Finding.objects.all()
if self.action == "providers":
return Finding.objects.all()
elif self.action == "findings":
return ScanSummary.objects.all()
elif self.action == "findings_severity":
return ScanSummary.objects.all()
else:
return super().get_queryset()
def get_serializer_class(self):
if self.action == "providers":
return OverviewProviderSerializer
elif self.action == "findings":
return OverviewFindingSerializer
elif self.action == "findings_severity":
return OverviewSeveritySerializer
return super().get_serializer_class()
def get_filterset_class(self):
if self.action == "providers":
return None
elif self.action in ["findings", "findings_severity"]:
return ScanSummaryFilter
return None
@extend_schema(exclude=True)
def list(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@@ -1366,7 +1451,7 @@ class OverviewViewSet(BaseRLSViewSet):
for res in resources_aggregated
if res["provider__provider"] == provider
),
0, # Default to 0 if no resources are found
0,
)
overview.append(
{
@@ -1382,3 +1467,132 @@ class OverviewViewSet(BaseRLSViewSet):
serializer = OverviewProviderSerializer(overview, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="findings")
def findings(self, request):
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
latest_scan_subquery = (
Scan.objects.filter(
state=StateChoices.COMPLETED, provider_id=OuterRef("scan__provider_id")
)
.order_by("-id")
.values("id")[:1]
)
annotated_queryset = filtered_queryset.annotate(
latest_scan_id=Subquery(latest_scan_subquery)
)
filtered_queryset = annotated_queryset.filter(scan_id=F("latest_scan_id"))
aggregated_totals = filtered_queryset.aggregate(
_pass=Sum("_pass") or 0,
fail=Sum("fail") or 0,
muted=Sum("muted") or 0,
total=Sum("total") or 0,
new=Sum("new") or 0,
changed=Sum("changed") or 0,
unchanged=Sum("unchanged") or 0,
fail_new=Sum("fail_new") or 0,
fail_changed=Sum("fail_changed") or 0,
pass_new=Sum("pass_new") or 0,
pass_changed=Sum("pass_changed") or 0,
muted_new=Sum("muted_new") or 0,
muted_changed=Sum("muted_changed") or 0,
)
for key in aggregated_totals:
if aggregated_totals[key] is None:
aggregated_totals[key] = 0
serializer = self.get_serializer(aggregated_totals)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="findings_severity")
def findings_severity(self, request):
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
latest_scan_subquery = (
Scan.objects.filter(
state=StateChoices.COMPLETED, provider_id=OuterRef("scan__provider_id")
)
.order_by("-id")
.values("id")[:1]
)
annotated_queryset = filtered_queryset.annotate(
latest_scan_id=Subquery(latest_scan_subquery)
)
filtered_queryset = annotated_queryset.filter(scan_id=F("latest_scan_id"))
severity_counts = (
filtered_queryset.values("severity")
.annotate(count=Sum("total"))
.order_by("severity")
)
severity_data = {sev[0]: 0 for sev in SeverityChoices}
for item in severity_counts:
severity_data[item["severity"]] = item["count"]
serializer = OverviewSeveritySerializer(severity_data)
return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema(tags=["Schedule"])
@extend_schema_view(
daily=extend_schema(
summary="Create a daily schedule scan for a given provider",
description="Schedules a daily scan for the specified provider. This endpoint creates a periodic task "
"that will execute a scan every 24 hours.",
request=ScheduleDailyCreateSerializer,
responses={202: OpenApiResponse(response=TaskSerializer)},
)
)
class ScheduleViewSet(BaseRLSViewSet):
# TODO: change to Schedule when implemented
queryset = Task.objects.none()
http_method_names = ["post"]
def get_queryset(self):
return super().get_queryset()
def get_serializer_class(self):
if self.action == "daily":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
return ScheduleDailyCreateSerializer
return super().get_serializer_class()
@extend_schema(exclude=True)
def create(self, request, *args, **kwargs):
raise MethodNotAllowed(method="POST")
@action(detail=False, methods=["post"], url_name="daily")
def daily(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
provider_id = serializer.validated_data["provider_id"]
provider_instance = get_object_or_404(Provider, pk=provider_id)
with transaction.atomic():
task = schedule_provider_scan(provider_instance)
prowler_task = Task.objects.get(id=task.id)
self.response_serializer_class = TaskSerializer
output_serializer = self.get_serializer(prowler_task)
return Response(
data=output_serializer.data,
status=status.HTTP_202_ACCEPTED,
headers={
"Content-Location": reverse(
"task-detail", kwargs={"pk": prowler_task.id}
)
},
)
+3 -2
View File
@@ -3,7 +3,7 @@ from celery import Celery, Task
celery_app = Celery("tasks")
celery_app.config_from_object("django.conf:settings", namespace="CELERY")
celery_app.conf.update(result_extended=True)
celery_app.conf.update(result_extended=True, result_expires=None)
celery_app.autodiscover_tasks(["api"])
@@ -20,9 +20,10 @@ class RLSTask(Task):
shadow=None,
**options,
):
from api.models import Task as APITask
from django_celery_results.models import TaskResult
from api.models import Task as APITask
result = super().apply_async(
args=args,
kwargs=kwargs,
@@ -9,3 +9,5 @@ CELERY_RESULT_BACKEND = "django-db"
CELERY_TASK_TRACK_STARTED = True
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_DEADLOCK_ATTEMPTS = env.int("DJANGO_CELERY_DEADLOCK_ATTEMPTS", default=5)
+26 -4
View File
@@ -1,7 +1,9 @@
import json
from datetime import datetime, timedelta, timezone
from django.utils import timezone
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from django_celery_beat.models import IntervalSchedule, PeriodicTask
from rest_framework_json_api.serializers import ValidationError
from tasks.tasks import perform_scheduled_scan_task
from api.models import Provider
@@ -16,7 +18,7 @@ def schedule_provider_scan(provider_instance: Provider):
task_name = f"scan-perform-scheduled-{provider_instance.id}"
# Schedule the task
PeriodicTask.objects.create(
_, created = PeriodicTask.objects.get_or_create(
interval=schedule,
name=task_name,
task="scan-perform-scheduled",
@@ -26,6 +28,26 @@ def schedule_provider_scan(provider_instance: Provider):
"provider_id": str(provider_instance.id),
}
),
start_time=provider_instance.inserted_at + timezone.timedelta(hours=24),
one_off=False,
defaults={
"start_time": datetime.now(timezone.utc) + timedelta(hours=24),
},
)
if not created:
raise ValidationError(
[
{
"detail": "There is already a scheduled scan for this provider.",
"status": 400,
"source": {"pointer": "/data/attributes/provider_id"},
"code": "invalid",
}
]
)
return perform_scheduled_scan_task.apply_async(
kwargs={
"tenant_id": str(provider_instance.tenant_id),
"provider_id": str(provider_instance.id),
},
)
+38 -12
View File
@@ -1,25 +1,51 @@
from celery.utils.log import get_task_logger
from django.db import transaction
from api.db_utils import batch_delete
from api.models import Finding, Provider, Resource, Scan, ScanSummary
logger = get_task_logger(__name__)
def delete_instance(model, pk: str):
def delete_provider(pk: str):
"""
Deletes an instance of the specified model.
This function retrieves an instance of the provided model using its primary key
and deletes it from the database.
Gracefully deletes an instance of a provider along with its related data.
Args:
model (Model): The Django model class from which to delete an instance.
pk (str): The primary key of the instance to delete.
pk (str): The primary key of the Provider instance to delete.
Returns:
tuple: A tuple containing the number of objects deleted and a dictionary
with the count of deleted objects per model,
including related models if applicable.
dict: A dictionary with the count of deleted objects per model,
including related models.
Raises:
model.DoesNotExist: If no instance with the provided primary key exists.
Provider.DoesNotExist: If no instance with the provided primary key exists.
"""
return model.objects.get(pk=pk).delete()
instance = Provider.all_objects.get(pk=pk)
deletion_summary = {}
with transaction.atomic():
# Delete Scan Summaries
scan_summaries_qs = ScanSummary.all_objects.filter(scan__provider=instance)
_, scans_summ_summary = batch_delete(scan_summaries_qs)
deletion_summary.update(scans_summ_summary)
# Delete Findings
findings_qs = Finding.all_objects.filter(scan__provider=instance)
_, findings_summary = batch_delete(findings_qs)
deletion_summary.update(findings_summary)
# Delete Resources
resources_qs = Resource.all_objects.filter(provider=instance)
_, resources_summary = batch_delete(resources_qs)
deletion_summary.update(resources_summary)
# Delete Scans
scans_qs = Scan.all_objects.filter(provider=instance)
_, scans_summary = batch_delete(scans_qs)
deletion_summary.update(scans_summary)
provider_deleted_count, provider_summary = instance.delete()
deletion_summary.update(provider_summary)
return deletion_summary
+223 -54
View File
@@ -3,8 +3,9 @@ from copy import deepcopy
from datetime import datetime, timezone
from celery.utils.log import get_task_logger
from prowler.lib.outputs.finding import Finding as ProwlerFinding
from prowler.lib.scan.scan import Scan as ProwlerScan
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
from django.db import IntegrityError, OperationalError
from django.db.models import Case, Count, IntegerField, Sum, When
from api.compliance import (
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
@@ -12,17 +13,20 @@ from api.compliance import (
)
from api.db_utils import tenant_transaction
from api.models import (
Provider,
Scan,
ComplianceOverview,
Finding,
Provider,
Resource,
ResourceTag,
StatusChoices as FindingStatus,
Scan,
ScanSummary,
StateChoices,
ComplianceOverview,
)
from api.models import StatusChoices as FindingStatus
from api.utils import initialize_prowler_provider
from api.v1.serializers import ScanTaskSerializer
from prowler.lib.outputs.finding import Finding as ProwlerFinding
from prowler.lib.scan.scan import Scan as ProwlerScan
logger = get_task_logger(__name__)
@@ -149,43 +153,57 @@ def perform_prowler_scan(
last_status_cache = {}
for progress, findings in prowler_scan.scan():
with tenant_transaction(tenant_id):
for finding in findings:
# Process resource
resource_uid = finding.resource_uid
if resource_uid not in resource_cache:
# Get or create the resource
resource_instance, _ = Resource.objects.get_or_create(
tenant_id=tenant_id,
provider=provider_instance,
uid=resource_uid,
defaults={
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
"name": finding.resource_name,
},
)
resource_cache[resource_uid] = resource_instance
else:
resource_instance = resource_cache[resource_uid]
for finding in findings:
for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
try:
with tenant_transaction(tenant_id):
# Process resource
resource_uid = finding.resource_uid
if resource_uid not in resource_cache:
# Get or create the resource
resource_instance, _ = Resource.objects.get_or_create(
tenant_id=tenant_id,
provider=provider_instance,
uid=resource_uid,
defaults={
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
"name": finding.resource_name,
},
)
resource_cache[resource_uid] = resource_instance
else:
resource_instance = resource_cache[resource_uid]
# Update resource fields if necessary
updated_fields = []
if resource_instance.region != finding.region:
resource_instance.region = finding.region
updated_fields.append("region")
if resource_instance.service != finding.service_name:
resource_instance.service = finding.service_name
updated_fields.append("service")
if resource_instance.type != finding.resource_type:
resource_instance.type = finding.resource_type
updated_fields.append("type")
if updated_fields:
resource_instance.save(update_fields=updated_fields)
# Update resource fields if necessary
updated_fields = []
if resource_instance.region != finding.region:
resource_instance.region = finding.region
updated_fields.append("region")
if resource_instance.service != finding.service_name:
resource_instance.service = finding.service_name
updated_fields.append("service")
if resource_instance.type != finding.resource_type:
resource_instance.type = finding.resource_type
updated_fields.append("type")
if updated_fields:
with tenant_transaction(tenant_id):
resource_instance.save(update_fields=updated_fields)
except (OperationalError, IntegrityError) as db_err:
if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
logger.warning(
f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
f"detected when processing resource {resource_uid} on scan {scan_id}. Retrying..."
)
time.sleep(0.1 * (2**attempt))
continue
else:
raise db_err
# Update tags
tags = []
# Update tags
tags = []
with tenant_transaction(tenant_id):
for key, value in finding.resource_tags.items():
tag_key = (key, value)
if tag_key not in tag_cache:
@@ -198,11 +216,10 @@ def perform_prowler_scan(
tags.append(tag_instance)
resource_instance.upsert_or_delete_tags(tags=tags)
unique_resources.add(
(resource_instance.uid, resource_instance.region)
)
unique_resources.add((resource_instance.uid, resource_instance.region))
# Process finding
# Process finding
with tenant_transaction(tenant_id):
finding_uid = finding.uid
if finding_uid not in last_status_cache:
most_recent_finding = (
@@ -239,15 +256,15 @@ def perform_prowler_scan(
)
finding_instance.add_resources([resource_instance])
# Update compliance data if applicable
if not generate_compliance or finding.status.value == "MUTED":
continue
# Update compliance data if applicable
if not generate_compliance or finding.status.value == "MUTED":
continue
region_dict = check_status_by_region.setdefault(finding.region, {})
current_status = region_dict.get(finding.check_id)
if current_status == "FAIL":
continue
region_dict[finding.check_id] = finding.status.value
region_dict = check_status_by_region.setdefault(finding.region, {})
current_status = region_dict.get(finding.check_id)
if current_status == "FAIL":
continue
region_dict[finding.check_id] = finding.status.value
# Update scan progress
with tenant_transaction(tenant_id):
@@ -268,7 +285,7 @@ def perform_prowler_scan(
scan_instance.unique_resource_count = len(unique_resources)
scan_instance.save()
if generate_compliance:
if exception is None and generate_compliance:
try:
regions = prowler_provider.get_regions()
except AttributeError:
@@ -321,3 +338,155 @@ def perform_prowler_scan(
serializer = ScanTaskSerializer(instance=scan_instance)
return serializer.data
def aggregate_findings(tenant_id: str, scan_id: str):
"""
Aggregates findings for a given scan and stores the results in the ScanSummary table.
This function retrieves all findings associated with a given `scan_id` and calculates various
metrics such as counts of failed, passed, and muted findings, as well as their deltas (new,
changed, unchanged). The results are grouped by `check_id`, `service`, `severity`, and `region`.
These aggregated metrics are then stored in the `ScanSummary` table.
Args:
tenant_id (str): The ID of the tenant to which the scan belongs.
scan_id (str): The ID of the scan for which findings need to be aggregated.
Aggregated Metrics:
- fail: Total number of failed findings.
- _pass: Total number of passed findings.
- muted: Total number of muted findings.
- total: Total number of findings.
- new: Total number of new findings.
- changed: Total number of changed findings.
- unchanged: Total number of unchanged findings.
- fail_new: Failed findings with a delta of 'new'.
- fail_changed: Failed findings with a delta of 'changed'.
- pass_new: Passed findings with a delta of 'new'.
- pass_changed: Passed findings with a delta of 'changed'.
- muted_new: Muted findings with a delta of 'new'.
- muted_changed: Muted findings with a delta of 'changed'.
"""
with tenant_transaction(tenant_id):
findings = Finding.objects.filter(scan_id=scan_id)
aggregation = findings.values(
"check_id",
"resources__service",
"severity",
"resources__region",
).annotate(
fail=Sum(
Case(
When(status="FAIL", then=1),
default=0,
output_field=IntegerField(),
)
),
_pass=Sum(
Case(
When(status="PASS", then=1),
default=0,
output_field=IntegerField(),
)
),
muted=Sum(
Case(
When(status="MUTED", then=1),
default=0,
output_field=IntegerField(),
)
),
total=Count("id"),
new=Sum(
Case(
When(delta="new", then=1),
default=0,
output_field=IntegerField(),
)
),
changed=Sum(
Case(
When(delta="changed", then=1),
default=0,
output_field=IntegerField(),
)
),
unchanged=Sum(
Case(
When(delta__isnull=True, then=1),
default=0,
output_field=IntegerField(),
)
),
fail_new=Sum(
Case(
When(delta="new", status="FAIL", then=1),
default=0,
output_field=IntegerField(),
)
),
fail_changed=Sum(
Case(
When(delta="changed", status="FAIL", then=1),
default=0,
output_field=IntegerField(),
)
),
pass_new=Sum(
Case(
When(delta="new", status="PASS", then=1),
default=0,
output_field=IntegerField(),
)
),
pass_changed=Sum(
Case(
When(delta="changed", status="PASS", then=1),
default=0,
output_field=IntegerField(),
)
),
muted_new=Sum(
Case(
When(delta="new", status="MUTED", then=1),
default=0,
output_field=IntegerField(),
)
),
muted_changed=Sum(
Case(
When(delta="changed", status="MUTED", then=1),
default=0,
output_field=IntegerField(),
)
),
)
with tenant_transaction(tenant_id):
scan_aggregations = {
ScanSummary(
tenant_id=tenant_id,
scan_id=scan_id,
check_id=agg["check_id"],
service=agg["resources__service"],
severity=agg["severity"],
region=agg["resources__region"],
fail=agg["fail"],
_pass=agg["_pass"],
muted=agg["muted"],
total=agg["total"],
new=agg["new"],
changed=agg["changed"],
unchanged=agg["unchanged"],
fail_new=agg["fail_new"],
fail_changed=agg["fail_changed"],
pass_new=agg["pass_new"],
pass_changed=agg["pass_changed"],
muted_new=agg["muted_new"],
muted_changed=agg["muted_changed"],
)
for agg in aggregation
}
ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000)
+30 -6
View File
@@ -1,12 +1,15 @@
from datetime import datetime, timedelta, timezone
from celery import shared_task
from config.celery import RLSTask
from django_celery_beat.models import PeriodicTask
from tasks.jobs.connection import check_provider_connection
from tasks.jobs.deletion import delete_provider
from tasks.jobs.scan import aggregate_findings, perform_prowler_scan
from api.db_utils import tenant_transaction
from api.decorators import set_tenant
from api.models import Provider, Scan
from config.celery import RLSTask
from tasks.jobs.connection import check_provider_connection
from tasks.jobs.deletion import delete_instance
from tasks.jobs.scan import perform_prowler_scan
@shared_task(base=RLSTask, name="provider-connection-check")
@@ -32,6 +35,8 @@ def delete_provider_task(provider_id: str):
"""
Task to delete a specific Provider instance.
It will delete in batches all the related resources first.
Args:
provider_id (str): The primary key of the `Provider` instance to be deleted.
@@ -41,7 +46,7 @@ def delete_provider_task(provider_id: str):
- A dictionary with the count of deleted instances per model,
including related models if cascading deletes were triggered.
"""
return delete_instance(model=Provider, pk=provider_id)
return delete_provider(pk=provider_id)
@shared_task(base=RLSTask, name="scan-perform", queue="scans")
@@ -96,17 +101,36 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
with tenant_transaction(tenant_id):
provider_instance = Provider.objects.get(pk=provider_id)
periodic_task_instance = PeriodicTask.objects.get(
name=f"scan-perform-scheduled-{provider_id}"
)
next_scan_date = datetime.combine(
datetime.now(timezone.utc), periodic_task_instance.start_time.time()
) + timedelta(hours=24)
scan_instance = Scan.objects.create(
tenant_id=tenant_id,
name="Daily scheduled scan",
provider=provider_instance,
trigger=Scan.TriggerChoices.SCHEDULED,
next_scan_at=next_scan_date,
task_id=task_id,
)
return perform_prowler_scan(
result = perform_prowler_scan(
tenant_id=tenant_id,
scan_id=str(scan_instance.id),
provider_id=provider_id,
)
perform_scan_summary_task.apply_async(
kwargs={
"tenant_id": tenant_id,
"scan_id": str(scan_instance.id),
}
)
return result
@shared_task(name="scan-summary")
def perform_scan_summary_task(tenant_id: str, scan_id: str):
return aggregate_findings(tenant_id=tenant_id, scan_id=scan_id)
+53
View File
@@ -0,0 +1,53 @@
import json
from unittest.mock import patch
import pytest
from django_celery_beat.models import IntervalSchedule, PeriodicTask
from rest_framework_json_api.serializers import ValidationError
from tasks.beat import schedule_provider_scan
@pytest.mark.django_db
class TestScheduleProviderScan:
def test_schedule_provider_scan_success(self, providers_fixture):
provider_instance, *_ = providers_fixture
with patch(
"tasks.tasks.perform_scheduled_scan_task.apply_async"
) as mock_apply_async:
result = schedule_provider_scan(provider_instance)
assert result is not None
mock_apply_async.assert_called_once_with(
kwargs={
"tenant_id": str(provider_instance.tenant_id),
"provider_id": str(provider_instance.id),
},
)
task_name = f"scan-perform-scheduled-{provider_instance.id}"
periodic_task = PeriodicTask.objects.get(name=task_name)
assert periodic_task is not None
assert periodic_task.interval.every == 24
assert periodic_task.interval.period == IntervalSchedule.HOURS
assert periodic_task.task == "scan-perform-scheduled"
assert json.loads(periodic_task.kwargs) == {
"tenant_id": str(provider_instance.tenant_id),
"provider_id": str(provider_instance.id),
}
def test_schedule_provider_scan_already_exists(self, providers_fixture):
provider_instance, *_ = providers_fixture
# First, schedule the scan
with patch("tasks.tasks.perform_scheduled_scan_task.apply_async"):
schedule_provider_scan(provider_instance)
# Now, try scheduling again, should raise ValidationError
with pytest.raises(ValidationError) as exc_info:
schedule_provider_scan(provider_instance)
assert "There is already a scheduled scan for this provider." in str(
exc_info.value
)
+3 -3
View File
@@ -1,15 +1,15 @@
import pytest
from django.core.exceptions import ObjectDoesNotExist
from tasks.jobs.deletion import delete_provider
from api.models import Provider
from tasks.jobs.deletion import delete_instance
@pytest.mark.django_db
class TestDeleteInstance:
def test_delete_instance_success(self, providers_fixture):
instance = providers_fixture[0]
result = delete_instance(Provider, instance.id)
result = delete_provider(instance.id)
assert result
with pytest.raises(ObjectDoesNotExist):
@@ -19,4 +19,4 @@ class TestDeleteInstance:
non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645"
with pytest.raises(ObjectDoesNotExist):
delete_instance(Provider, non_existent_pk)
delete_provider(non_existent_pk)
+14 -11
View File
@@ -1,19 +1,19 @@
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
import pytest
from api.models import (
StateChoices,
Severity,
Finding,
Resource,
StatusChoices,
Provider,
)
from tasks.jobs.scan import (
perform_prowler_scan,
_create_finding_delta,
_store_resources,
perform_prowler_scan,
)
from api.models import (
Finding,
Provider,
Resource,
Severity,
StateChoices,
StatusChoices,
)
@@ -358,3 +358,6 @@ class TestPerformScan:
assert resource == resource_instance
assert resource_uid_tuple == (resource_instance.uid, resource_instance.region)
# TODO Add tests for aggregations
+111
View File
@@ -0,0 +1,111 @@
services:
api-dev:
hostname: "prowler-api"
build:
context: ./api
dockerfile: Dockerfile
target: dev
environment:
- DJANGO_SETTINGS_MODULE=config.django.devel
- DJANGO_LOGGING_FORMATTER=${LOGGING_FORMATTER:-human_readable}
env_file:
- path: .env
required: false
ports:
- "${DJANGO_PORT:-8080}:${DJANGO_PORT:-8080}"
volumes:
- "./api/src/backend:/home/prowler/backend"
- "./api/pyproject.toml:/home/prowler/pyproject.toml"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
entrypoint:
- "/home/prowler/docker-entrypoint.sh"
- "dev"
ui-dev:
build:
context: ./ui
dockerfile: Dockerfile
target: dev
env_file:
- path: .env
required: false
ports:
- 3000:3000
postgres:
image: postgres:16.3-alpine
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_ADMIN_USER}
- POSTGRES_PASSWORD=${POSTGRES_ADMIN_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
env_file:
- path: .env
required: false
ports:
- "${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}"
healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U ${POSTGRES_ADMIN_USER} -d ${POSTGRES_DB}'"]
interval: 5s
timeout: 5s
retries: 5
valkey:
image: valkey/valkey:7-alpine3.19
hostname: "valkey"
volumes:
- ./api/_data/valkey:/data
env_file:
- path: .env
required: false
ports:
- "${VALKEY_PORT:-6379}:6379"
healthcheck:
test: ["CMD-SHELL", "sh -c 'valkey-cli ping'"]
interval: 10s
timeout: 5s
retries: 3
worker-dev:
build:
context: ./api
dockerfile: Dockerfile
target: dev
environment:
- DJANGO_SETTINGS_MODULE=config.django.devel
env_file:
- path: .env
required: false
depends_on:
valkey:
condition: service_healthy
postgres:
condition: service_healthy
entrypoint:
- "/home/prowler/docker-entrypoint.sh"
- "worker"
worker-beat:
build:
context: ./api
dockerfile: Dockerfile
target: dev
environment:
- DJANGO_SETTINGS_MODULE=config.django.devel
env_file:
- path: ./.env
required: false
depends_on:
valkey:
condition: service_healthy
postgres:
condition: service_healthy
entrypoint:
- "../docker-entrypoint.sh"
- "beat"
+89
View File
@@ -0,0 +1,89 @@
services:
api:
hostname: "prowler-api"
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-latest}
env_file:
- path: .env
required: false
ports:
- "${DJANGO_PORT:-8080}:${DJANGO_PORT:-8080}"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
entrypoint:
- "/home/prowler/docker-entrypoint.sh"
- "prod"
ui:
image: prowlercloud/prowler-ui:${PROWLER_UI_VERSION:-latest}
env_file:
- path: .env
required: false
ports:
- ${UI_PORT:-3000}:${UI_PORT:-3000}
postgres:
image: postgres:16.3-alpine
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_ADMIN_USER}
- POSTGRES_PASSWORD=${POSTGRES_ADMIN_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
env_file:
- path: .env
required: false
ports:
- "${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}"
healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U ${POSTGRES_ADMIN_USER} -d ${POSTGRES_DB}'"]
interval: 5s
timeout: 5s
retries: 5
valkey:
image: valkey/valkey:7-alpine3.19
hostname: "valkey"
volumes:
- ./_data/valkey:/data
env_file:
- path: .env
required: false
ports:
- "${VALKEY_PORT:-6379}:6379"
healthcheck:
test: ["CMD-SHELL", "sh -c 'valkey-cli ping'"]
interval: 10s
timeout: 5s
retries: 3
worker:
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-latest}
env_file:
- path: .env
required: false
depends_on:
valkey:
condition: service_healthy
postgres:
condition: service_healthy
entrypoint:
- "/home/prowler/docker-entrypoint.sh"
- "worker"
worker-beat:
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-latest}
env_file:
- path: ./.env
required: false
depends_on:
valkey:
condition: service_healthy
postgres:
condition: service_healthy
entrypoint:
- "../docker-entrypoint.sh"
- "beat"
Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

+161 -10
View File
@@ -1,6 +1,14 @@
**Prowler** is an Open Source security tool to perform AWS, Azure, Google Cloud and Kubernetes security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness, and also remediations! We have Prowler CLI (Command Line Interface) that we call Prowler Open Source and a service on top of it that we call <a href="https://prowler.com">Prowler SaaS</a>.
## Prowler CLI
## Prowler App
![Prowler App](img/overview.png)
Prowler App is a web application that allows you to run Prowler in a simple way. It provides a user-friendly interface to configure and run scans, view results, and manage your security findings.
See how to install the Prowler App in the [Quick Start](#prowler-app-installation) section.
## Prowler CLI
```console
prowler <provider>
@@ -17,7 +25,95 @@ prowler dashboard
It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks.
## Quick Start
### Installation
### Prowler App Installation
Prowler App can be installed in different ways, depending on your environment:
> See how to use Prowler App in the [Prowler App](tutorials/prowler-app.md) section.
=== "Docker Compose"
_Requirements_:
* `Docker Compose` installed: https://docs.docker.com/compose/install/.
_Commands_:
``` bash
curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/docker-compose.yml
curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/.env
docker compose up -d
```
> Enjoy Prowler App at http://localhost:3000 by signing up with your email and password.
???+ note
You can change the environment variables in the `.env` file. Note that it is not recommended to use the default values in production environments.
???+ note
There is a development mode available, you can use the file https://github.com/prowler-cloud/prowler/blob/master/docker-compose.dev.yml to run the app in development mode.
???+ warning
Google and GitHub authentication is only available in [Prowler Cloud](https://prowler.com).
=== "GitHub"
_Requirements_:
* `git` installed.
* `poetry` installed: [poetry installation](https://python-poetry.org/docs/#installation).
* `npm` installed: [npm installation](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm).
* `Docker Compose` installed: https://docs.docker.com/compose/install/.
_Commands to run the API_:
``` bash
git clone https://github.com/prowler-cloud/prowler \
cd prowler/api \
poetry install \
poetry shell \
set -a \
source .env \
docker compose up postgres valkey -d \
cd src/backend \
python manage.py migrate --database admin \
gunicorn -c config/guniconf.py config.wsgi:application
```
> Now, you can access the API documentation at http://localhost:8080/api/v1/docs.
_Commands to run the API Worker_:
``` bash
git clone https://github.com/prowler-cloud/prowler \
cd prowler/api \
poetry install \
poetry shell \
set -a \
source .env \
cd src/backend \
python -m celery -A config.celery worker -l info -E
```
_Commands to run the UI_:
``` bash
git clone https://github.com/prowler-cloud/prowler \
cd prowler/ui \
npm install \
npm run build \
npm start
```
> Enjoy Prowler App at http://localhost:3000 by signing up with your email and password.
???+ warning
Make sure to have `api/.env` and `ui/.env.local` files with the required environment variables. You can find the required environment variables in the [`api/.env.template`](https://github.com/prowler-cloud/prowler/blob/master/api/.env.example) and [`ui/.env.template`](https://github.com/prowler-cloud/prowler/blob/master/ui/.env.template) files.
???+ warning
Google and GitHub authentication is only available in [Prowler Cloud](https://prowler.com).
### Prowler CLI Installation
Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/), thus can be installed as Python package with `Python >= 3.9`:
@@ -195,18 +291,23 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/),
## Prowler container versions
The available versions of Prowler are the following:
The available versions of Prowler CLI are the following:
- `latest`: in sync with `master` branch (bear in mind that it is not a stable version)
- `v4-latest`: in sync with `v4` branch (bear in mind that it is not a stable version)
- `v3-latest`: in sync with `v3` branch (bear in mind that it is not a stable version)
- `<x.y.z>` (release): you can find the releases [here](https://github.com/prowler-cloud/prowler/releases), those are stable releases.
- `stable`: this tag always point to the latest release.
- `v4-stable`: this tag always point to the latest release for v4.
- `v3-stable`: this tag always point to the latest release for v3.
The container images are available here:
- [DockerHub](https://hub.docker.com/r/toniblyx/prowler/tags)
- [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler)
- Prowler CLI:
- [DockerHub](https://hub.docker.com/r/toniblyx/prowler/tags)
- [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler)
- Prowler App:
- [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags)
- [DockerHub - Prowler API](https://hub.docker.com/r/prowlercloud/prowler-api/tags)
## High level architecture
@@ -214,6 +315,22 @@ You can run Prowler from your workstation, a Kubernetes Job, a Google Compute En
![Architecture](img/architecture.png)
### Prowler App
The **Prowler App** consists of three main components:
- **Prowler UI**: A user-friendly web interface for running Prowler and viewing results, powered by Next.js.
- **Prowler API**: The backend API that executes Prowler scans and stores the results, built with Django REST Framework.
- **Prowler SDK**: A Python SDK that integrates with the Prowler CLI for advanced functionality.
The app leverages the following supporting infrastructure:
- **PostgreSQL**: Used for persistent storage of scan results.
- **Celery Workers**: Facilitate asynchronous execution of Prowler scans.
- **Valkey**: An in-memory database serving as a message broker for the Celery workers.
![Prowler App Architecture](img/prowler-app-architecture.png)
## Deprecations from v3
### General
@@ -231,6 +348,40 @@ We have deprecated some of our outputs formats:
- To send only FAILS to AWS Security Hub, now use either `--send-sh-only-fails` or `--security-hub --status FAIL`.
## Basic Usage
### Prowler App
#### **Access the App**
Go to [http://localhost:3000](http://localhost:3000) after installing the app (see [Quick Start](#prowler-app-installation)). Sign up with your email and password.
<img src="img/sign-up-button.png" alt="Sign Up Button" width="320"/>
<img src="img/sign-up.png" alt="Sign Up" width="285"/>
#### **Log In**
Log in with your email and password to start using the Prowler App.
<img src="img/log-in.png" alt="Log In" width="285"/>
#### **Add a Provider**
- Go to `Settings > Cloud Providers` and click `Add Account`.
- Select the provider you want to scan (AWS, GCP, Azure, Kubernetes).
- Enter the provider's ID (AWS Account ID, GCP Project ID, Azure Subscription ID, Kubernetes Cluster) and optional alias.
- Follow the instructions to add your credentials.
#### **Start a Scan**
After successfully adding and testing your credentials, Prowler will start scanning your cloud environment, click on the `Go to Scans` button to see the progress.
#### **View Results**
While the scan is running, start exploring the findings in these sections:
- **Overview**: High-level summary of the scans. <img src="../../img/overview.png" alt="Overview" width="700"/>
- **Compliance**: Insights into compliance status. <img src="../../img/compliance.png" alt="Compliance" width="700"/>
> See more details about the Prowler App usage in the [Prowler App](tutorials/prowler-app.md) section.
???+ note
Prowler will automatically scan all configured providers every **24 hours**, ensuring your cloud environment stays continuously monitored.
### Prowler CLI
To run Prowler, you will need to specify the provider (e.g `aws`, `gcp`, `azure` or `kubernetes`):
@@ -289,7 +440,7 @@ You can always use `-h`/`--help` to access to the usage information and all the
prowler --help
```
### AWS
#### AWS
Use a custom AWS profile with `-p`/`--profile` and/or AWS regions which you want to audit with `-f`/`--filter-region`:
@@ -302,7 +453,7 @@ prowler aws --profile custom-profile -f us-east-1 eu-south-2
See more details about AWS Authentication in [Requirements](getting-started/requirements.md#aws)
### Azure
#### Azure
With Azure you need to specify which auth method is going to be used:
@@ -327,7 +478,7 @@ Prowler by default scans all the subscriptions that is allowed to scan, if you w
prowler azure --az-cli-auth --subscription-ids <subscription ID 1> <subscription ID 2> ... <subscription ID N>
```
### Google Cloud
#### Google Cloud
Prowler will use by default your User Account credentials, you can configure it using:
@@ -349,7 +500,7 @@ prowler gcp --project-ids <Project ID 1> <Project ID 2> ... <Project ID N>
See more details about GCP Authentication in [Requirements](getting-started/requirements.md#google-cloud)
### Kubernetes
#### Kubernetes
Prowler allows you to scan your Kubernetes Cluster either from within the cluster or from outside the cluster.
@@ -11,7 +11,7 @@ To allow Prowler assume an identity to start the scan with the required privileg
7. Fill the "Description" and "Expires" fields and click on "Add"
8. Copy the value of the secret, it is going to be used as `AZURE_CLIENT_SECRET` environment variable.
![Register an Application page](../../img/create-sp.gif)
![Register an Application page](../img/create-sp.gif)
## Assigning the proper permissions
+132
View File
@@ -0,0 +1,132 @@
# Prowler App
The **Prowler App** is a user-friendly interface for the Prowler CLI, providing a visual dashboard to monitor your cloud security posture. This tutorial will guide you through setting up and using the Prowler App.
After [installing](../index.md#prowler-app-installation) the **Prowler App**, access it at [http://localhost:3000](http://localhost:3000).
You can also access to the auto-generated **Prowler API** documentation at [http://localhost:8080/api/v1/docs](http://localhost:8080/api/v1/docs) to see all the available endpoints, parameters and responses.
## **Step 1: Sign Up**
To get started, sign up using your email and password:
<img src="../../img/sign-up-button.png" alt="Sign Up Button" width="320"/>
<img src="../../img/sign-up.png" alt="Sign Up" width="285"/>
---
## **Step 2: Log In**
Once youve signed up, log in with your email and password to start using the Prowler App.
<img src="../../img/log-in.png" alt="Log In" width="350"/>
You will see the Overview page with no data yet, so let's start adding a provider to scan your cloud environment.
---
## **Step 3: Add a Provider**
To run your first scan, you need to add a cloud provider account. Prowler App supports AWS, Azure, GCP, and Kubernetes.
1. Navigate to `Settings > Cloud Providers`.
2. Click `Add Account` to set up a new provider and provide your credentials:
<img src="../../img/add-provider.png" alt="Add Provider" width="700"/>
---
## **Step 4: Configure the Provider**
Choose the provider you want to scan from the following options:
<img src="../../img/select-provider.png" alt="Select a Provider" width="700"/>
Once youve selected a provider, you need to provide the Provider UID:
- **AWS**: Enter your AWS Account ID.
- **GCP**: Enter your GCP Project ID.
- **Azure**: Enter your Azure Subscription ID.
- **Kubernetes**: Enter your Kubernetes Cluster name.
Optionally, provide a **Provider Alias** for easier identification. Follow the instructions provided to add your credentials:
---
### **Step 4.1: AWS Credentials**
For AWS, enter your `AWS Account ID` and choose one of the following methods to connect:
#### **Step 4.1.1: IAM Access Keys**
1. Select `Connect via Credentials`.
<img src="../../img/connect-aws-credentials.png" alt="AWS Credentials" width="350"/>
2. Enter your `Access Key ID`, `Secret Access Key` and optionally a `Session Token`:
<img src="../../img/aws-credentials.png" alt="AWS Credentials" width="350"/>
#### **Step 4.1.2: IAM Role**
1. Select `Connect assuming IAM Role`.
<img src="../../img/connect-aws-role.png" alt="AWS Role" width="350"/>
2. Enter the `Role ARN` and any optional field like the AWS Access Keys to assume the role, the `External ID`, the `Role Session Name` or the `Session Duration`:
<img src="../../img/aws-role.png" alt="AWS Role" width="700"/>
---
### **Step 4.2: Azure Credentials**
For Azure, Prowler App uses a Service Principal to authenticate. See the steps in https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/azure/create-prowler-service-principal/ to create a Service Principal. Then, enter the `Tenant ID`, `Client ID` and `Client Secret` of the Service Principal.
<img src="../../img/azure-credentials.png" alt="Azure Credentials" width="700"/>
---
### **Step 4.3: GCP Credentials**
To connect your GCP Project, you need to use the Application Default Credentials (ADC) returned by the `gcloud` CLI. Heres how to set up:
1. Run the following command in your terminal to authenticate with GCP:
```bash
gcloud auth application-default login
```
2. Once authenticated, get the `Client ID`, `Client Secret` and `Refresh Token` from `~/.config/gcloud/application_default_credentials`.
3. Paste the `Client ID`, `Client Secret` and `Refresh Token` into the Prowler App.
<img src="../../img/gcp-credentials.png" alt="GCP Credentials" width="700"/>
---
### **Step 4.4: Kubernetes Credentials**
For Kubernetes, Prowler App uses a `kubeconfig` file to authenticate, paste the contents of your `kubeconfig` file into the `Kubeconfig content` field.
By default, the `kubeconfig` file is located at `~/.kube/config`.
<img src="../../img/kubernetes-credentials.png" alt="Kubernetes Credentials" width="700"/>
---
## **Step 5: Test Connection**
After adding your credentials of your cloud account, click the `Launch` button to verify that the Prowler App can successfully connect to your provider:
<img src="../../img/test-connection-button.png" alt="Test Connection" width="700"/>
## **Step 6: Scan started**
After successfully adding and testing your credentials, Prowler will start scanning your cloud environment, click on the `Go to Scans` button to see the progress:
<img src="../../img/provider-added.png" alt="Start Now" width="700"/>
???+ note
Prowler will automatically scan all configured providers every **24 hours**, ensuring your cloud environment stays continuously monitored.
---
## **Step 7: Monitor Scan Progress**
Track the progress of your scan in the `Scans` section:
<img src="../../img/scan-progress.png" alt="Scan Progress" width="700"/>
---
## **Step 8: Analyze the Findings**
While the scan is running, start exploring the findings in these sections:
- **Overview**: High-level summary of the scans. <img src="../../img/overview.png" alt="Overview" width="700"/>
- **Compliance**: Insights into compliance status. <img src="../../img/compliance.png" alt="Compliance" width="700"/>
- **Issues**: Types of issues detected.
<img src="../../img/issues.png" alt="Issues" width="300" style="text-align: center;"/>
- **Browse All Findings**: Detailed list of findings detected, where you can filter by severity, service, and more. <img src="../../img/findings.png" alt="Findings" width="700"/>
+2
View File
@@ -11,6 +11,7 @@ theme:
name: material
favicon: favicon.ico
features:
- content.code.copy
- navigation.tabs
- navigation.tabs.sticky
- navigation.sections
@@ -49,6 +50,7 @@ nav:
- Overview: index.md
- Requirements: getting-started/requirements.md
- Tutorials:
- Prowler App: tutorials/prowler-app.md
- Miscellaneous: tutorials/misc.md
- Reporting: tutorials/reporting.md
- Compliance: tutorials/compliance.md
@@ -7,6 +7,7 @@
"appstream:Describe*",
"appstream:List*",
"backup:List*",
"backup:Get*",
"bedrock:List*",
"bedrock:Get*",
"cloudtrail:GetInsightSelectors",
@@ -29,6 +30,7 @@
"glue:GetConnections",
"glue:GetSecurityConfiguration*",
"glue:SearchTables",
"glue:GetMLTransforms",
"lambda:GetFunction*",
"logs:FilterLogEvents",
"lightsail:GetRelationalDatabases",
Generated
+13 -13
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -694,13 +694,13 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
[[package]]
name = "bandit"
version = "1.7.10"
version = "1.8.0"
description = "Security oriented static analyser for python code."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "bandit-1.7.10-py3-none-any.whl", hash = "sha256:665721d7bebbb4485a339c55161ac0eedde27d51e638000d91c8c2d68343ad02"},
{file = "bandit-1.7.10.tar.gz", hash = "sha256:59ed5caf5d92b6ada4bf65bc6437feea4a9da1093384445fed4d472acc6cff7b"},
{file = "bandit-1.8.0-py3-none-any.whl", hash = "sha256:b1a61d829c0968aed625381e426aa378904b996529d048f8d908fa28f6b13e38"},
{file = "bandit-1.8.0.tar.gz", hash = "sha256:b5bfe55a095abd9fe20099178a7c6c060f844bfd4fe4c76d28e35e4c52b9d31e"},
]
[package.dependencies]
@@ -775,17 +775,17 @@ files = [
[[package]]
name = "boto3"
version = "1.35.66"
version = "1.35.71"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.35.66-py3-none-any.whl", hash = "sha256:09a610f8cf4d3c22d4ca69c1f89079e3a1c82805ce94fa0eb4ecdd4d2ba6c4bc"},
{file = "boto3-1.35.66.tar.gz", hash = "sha256:c392b9168b65e9c23483eaccb5b68d1f960232d7f967a1e00a045ba065ce050d"},
{file = "boto3-1.35.71-py3-none-any.whl", hash = "sha256:e2969a246bb3208122b3c349c49cc6604c6fc3fc2b2f65d99d3e8ccd745b0c16"},
{file = "boto3-1.35.71.tar.gz", hash = "sha256:3ed7172b3d4fceb6218bb0ec3668c4d40c03690939c2fca4f22bb875d741a07f"},
]
[package.dependencies]
botocore = ">=1.35.66,<1.36.0"
botocore = ">=1.35.71,<1.36.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@@ -794,13 +794,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.35.70"
version = "1.35.71"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.35.70-py3-none-any.whl", hash = "sha256:ba8a4797cf7c5d9c237e67a62692f5146e895613fd3e6a43b00b66f3a8c7fc73"},
{file = "botocore-1.35.70.tar.gz", hash = "sha256:18d1bb505722d9efd50c50719ed8de7284bfe6d3908a9e08756a7646e549da21"},
{file = "botocore-1.35.71-py3-none-any.whl", hash = "sha256:fc46e7ab1df3cef66dfba1633f4da77c75e07365b36f03bd64a3793634be8fc1"},
{file = "botocore-1.35.71.tar.gz", hash = "sha256:f9fa058e0393660c3fe53c1e044751beb64b586def0bd2212448a7c328b0cbba"},
]
[package.dependencies]
@@ -5239,4 +5239,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.9,<3.13"
content-hash = "3e674bb39019458d469da43301c0ee7607277bd38ab294b6190935882eb280b8"
content-hash = "c6497888031fcb8ddece4848532980baa412943e90fa8298dd0e9671fbfb5391"
-13
View File
@@ -556,19 +556,6 @@ def execute_checks(
bar()
bar.title = f"-> {Fore.GREEN}Scan completed!{Style.RESET_ALL}"
# Custom report interface
if os.environ.get("PROWLER_REPORT_LIB_PATH"):
try:
logger.info("Using custom report interface ...")
lib = os.environ["PROWLER_REPORT_LIB_PATH"]
outputs_module = importlib.import_module(lib)
custom_report_interface = getattr(outputs_module, "report")
# TODO: review this call and see if we can remove the global_provider.output_options since it is contained in the global_provider
custom_report_interface(check_findings, output_options, global_provider)
except Exception:
sys.exit(1)
return all_findings
+1 -1
View File
@@ -111,7 +111,7 @@ def load_checks_to_execute(
):
checks_to_execute.add(check_name)
# Only execute threat detection checks if threat-detection category is set
if categories and categories != [] and "threat-detection" not in categories:
if not categories or "threat-detection" not in categories:
for threat_detection_check in check_categories.get("threat-detection", []):
checks_to_execute.discard(threat_detection_check)
+3 -2
View File
@@ -322,8 +322,9 @@ class CheckMetadata(BaseModel):
checks = set()
if service:
if service == "lambda":
service = "awslambda"
# This is a special case for the AWS provider since `lambda` is a reserved keyword in Python
if service == "awslambda":
service = "lambda"
checks = {
check_name
for check_name, check_metadata in bulk_checks_metadata.items()
@@ -9265,10 +9265,7 @@
"us-west-2"
],
"aws-cn": [],
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
]
"aws-us-gov": []
}
},
"sagemaker-runtime": {
@@ -446,7 +446,7 @@ class RDS(AWSService):
arn=arn,
sns_topic_arn=event["SnsTopicArn"],
status=event["Status"],
source_type=event["SourceType"],
source_type=event.get("SourceType", ""),
source_id=event.get("SourceIdsList", []),
event_list=event.get("EventCategoriesList", []),
enabled=event["Enabled"],
@@ -18,12 +18,7 @@ class containerregistry_not_publicly_accessible(Check):
report.status = "FAIL"
report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription} allows unrestricted network access."
if (
getattr(
container_registry_info.network_rule_set, "default_action", ""
).lower()
== "deny"
):
if not container_registry_info.public_network_access:
report.status = "PASS"
report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription} does not allow unrestricted network access."
@@ -37,8 +37,13 @@ class ContainerRegistry(AzureService):
resource_group=resource_group,
sku=getattr(registry.sku, "name", ""),
login_server=getattr(registry, "login_server", ""),
public_network_access=getattr(
registry, "public_network_access", ""
public_network_access=(
False
if getattr(
registry, "public_network_access" "Enabled"
)
== "Disabled"
else True
),
admin_user_enabled=getattr(
registry, "admin_user_enabled", False
@@ -93,7 +98,7 @@ class ContainerRegistryInfo:
resource_group: str
sku: str
login_server: str
public_network_access: str
public_network_access: bool
admin_user_enabled: bool
network_rule_set: NetworkRuleSet
monitor_diagnostic_settings: list[DiagnosticSetting]
+3 -1
View File
@@ -55,7 +55,9 @@ class GCPService:
project_ids = []
for project_id in audited_project_ids:
try:
client = discovery.build("serviceusage", "v1")
client = discovery.build(
"serviceusage", "v1", credentials=self.credentials
)
request = client.services().get(
name=f"projects/{project_id}/services/{self.service}.googleapis.com"
)
@@ -1,4 +1,5 @@
import os
from typing import Union
from colorama import Fore, Style
from kubernetes.client.exceptions import ApiException
@@ -74,14 +75,14 @@ class KubernetesProvider(Provider):
fixer_config: dict = {},
mutelist_path: str = None,
mutelist_content: dict = {},
kubeconfig_content: dict = None,
kubeconfig_content: Union[dict, str] = None,
):
"""
Initializes the KubernetesProvider instance.
Args:
kubeconfig_file (str): Path to the kubeconfig file.
kubeconfig_content (dict): Content of the kubeconfig file.
kubeconfig_content (str or dict): Content of the kubeconfig file.
context (str): Context name.
namespace (list): List of namespaces.
config_content (dict): Audit configuration.
@@ -224,7 +225,7 @@ class KubernetesProvider(Provider):
@staticmethod
def setup_session(
kubeconfig_file: str = None,
kubeconfig_content: dict = None,
kubeconfig_content: Union[dict, str] = None,
context: str = None,
) -> KubernetesSession:
"""
@@ -232,7 +233,7 @@ class KubernetesProvider(Provider):
Args:
kubeconfig_file (str): Path to the kubeconfig file.
kubeconfig_content (dict): Content of the kubeconfig file.
kubeconfig_content (str or dict): Content of the kubeconfig file.
context (str): Context name.
Returns:
@@ -243,14 +244,20 @@ class KubernetesProvider(Provider):
KubernetesInvalidProviderIdError: If the provider ID is invalid.
KubernetesSetUpSessionError: If an error occurs while setting up the session.
"""
logger.info(f"Using kubeconfig file: {kubeconfig_file}")
try:
if kubeconfig_content:
config.load_kube_config_from_dict(
safe_load(kubeconfig_content), context=context
)
logger.info("Using kubeconfig content...")
config_data = safe_load(kubeconfig_content)
config.load_kube_config_from_dict(config_data, context=context)
if context:
contexts = config_data.get("contexts", [])
for context_item in contexts:
if context_item["name"] == context:
context = context_item
else:
context = config_data.get("contexts", [])[0]
else:
logger.info(f"Using kubeconfig file: {kubeconfig_file}...")
kubeconfig_file = (
kubeconfig_file if kubeconfig_file else "~/.kube/config"
)
@@ -273,17 +280,19 @@ class KubernetesProvider(Provider):
return KubernetesSession(
api_client=client.ApiClient(), context=context
)
if context:
contexts = config.list_kube_config_contexts(
config_file=kubeconfig_file
)[0]
for context_item in contexts:
if context_item["name"] == context:
context = context_item
else:
context = config.list_kube_config_contexts(config_file=kubeconfig_file)[
1
]
if context:
contexts = config.list_kube_config_contexts(
config_file=kubeconfig_file
)[0]
for context_item in contexts:
if context_item["name"] == context:
context = context_item
else:
# If no context is provided, use the active context in the kubeconfig file
# The first element is the list of contexts, the second is the active context
context = config.list_kube_config_contexts(
config_file=kubeconfig_file
)[1]
return KubernetesSession(api_client=client.ApiClient(), context=context)
except parser.ParserError as parser_error:
@@ -318,7 +327,7 @@ class KubernetesProvider(Provider):
@staticmethod
def test_connection(
kubeconfig_file: str = "~/.kube/config",
kubeconfig_content: dict = None,
kubeconfig_content: Union[dict, str] = None,
namespace: str = None,
provider_id: str = None,
raise_on_exception: bool = True,
@@ -328,7 +337,7 @@ class KubernetesProvider(Provider):
Args:
kubeconfig_file (str): Path to the kubeconfig file.
kubeconfig_content (dict): Content of the kubeconfig file.
kubeconfig_content (str or dict): Content of the kubeconfig file.
namespace (str): Namespace name.
provider_id (str): Provider ID to use, in this case, the Kubernetes context.
raise_on_exception (bool): Whether to raise an exception on error.
@@ -352,7 +361,7 @@ class KubernetesProvider(Provider):
... )
- Using the kubeconfig content:
>>> connection = KubernetesProvider.test_connection(
... kubeconfig_content={"kubecofig": "content"},
... kubeconfig_content="kubeconfig content",
... namespace="default",
... provider_id="my-context",
... raise_on_exception=True,
+3 -3
View File
@@ -48,8 +48,8 @@ azure-mgmt-storage = "21.2.1"
azure-mgmt-subscription = "3.1.1"
azure-mgmt-web = "7.3.1"
azure-storage-blob = "12.24.0"
boto3 = "1.35.66"
botocore = "1.35.70"
boto3 = "1.35.71"
botocore = "1.35.71"
colorama = "0.4.6"
cryptography = "43.0.1"
dash = "2.18.2"
@@ -76,7 +76,7 @@ tabulate = "0.9.0"
tzlocal = "5.2"
[tool.poetry.group.dev.dependencies]
bandit = "1.7.10"
bandit = "1.8.0"
black = "24.10.0"
coverage = "7.6.8"
docker = "7.1.0"
+65 -11
View File
@@ -14,11 +14,13 @@ S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_CUSTOM_ALIAS = (
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_SEVERITY = "medium"
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_SERVICE = "s3"
CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME = "cloudtrail_threat_detection_enumeration"
class TestCheckLoader:
provider = "aws"
def get_custom_check_metadata(self):
def get_custom_check_s3_metadata(self):
return CheckMetadata(
Provider="aws",
CheckID=S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME,
@@ -52,9 +54,37 @@ class TestCheckLoader:
Compliance=[],
)
def get_threat_detection_check_metadata(self):
return CheckMetadata(
Provider="aws",
CheckID=CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME,
CheckTitle="Ensure there are no potential enumeration threats in CloudTrail",
CheckType=[],
ServiceName="cloudtrail",
SubServiceName="",
ResourceIdTemplate="arn:partition:service:region:account-id:resource-id",
Severity="critical",
ResourceType="AwsCloudTrailTrail",
Description="This check ensures that there are no potential enumeration threats in CloudTrail.",
Risk="Potential enumeration threats in CloudTrail can lead to unauthorized access to resources.",
RelatedUrl="",
Remediation=Remediation(
Code=Code(CLI="", NativeIaC="", Other="", Terraform=""),
Recommendation=Recommendation(
Text="To remediate this issue, ensure that there are no potential enumeration threats in CloudTrail.",
Url="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-concepts.html#cloudtrail-concepts-logging-data-events",
),
),
Categories=["threat-detection"],
DependsOn=[],
RelatedTo=[],
Notes="",
Compliance=[],
)
def test_load_checks_to_execute(self):
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute(
@@ -64,7 +94,7 @@ class TestCheckLoader:
def test_load_checks_to_execute_with_check_list(self):
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
check_list = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME]
@@ -76,7 +106,7 @@ class TestCheckLoader:
def test_load_checks_to_execute_with_severities(self):
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
severities = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_SEVERITY]
@@ -88,7 +118,7 @@ class TestCheckLoader:
def test_load_checks_to_execute_with_severities_and_services(self):
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
service_list = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_SERVICE]
severities = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_SEVERITY]
@@ -104,7 +134,7 @@ class TestCheckLoader:
self,
):
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
service_list = ["ec2"]
severities = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_SEVERITY]
@@ -120,7 +150,7 @@ class TestCheckLoader:
self,
):
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
checks_file = "path/to/test_file"
with patch(
@@ -137,7 +167,7 @@ class TestCheckLoader:
self,
):
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
service_list = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_SERVICE]
@@ -178,7 +208,7 @@ class TestCheckLoader:
self,
):
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
categories = {"internet-exposed"}
@@ -190,7 +220,7 @@ class TestCheckLoader:
def test_load_checks_to_execute_no_bulk_checks_metadata(self):
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
with patch(
"prowler.lib.check.checks_loader.CheckMetadata.get_bulk",
@@ -221,7 +251,7 @@ class TestCheckLoader:
compliance_frameworks = ["soc2_aws"]
bulk_checks_metatada = {
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_metadata()
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata()
}
with patch(
"prowler.lib.check.checks_loader.CheckMetadata.get_bulk",
@@ -248,3 +278,27 @@ class TestCheckLoader:
assert {"check1_name", "check2_name"} == update_checks_to_execute_with_aliases(
checks_to_execute, check_aliases
)
def test_threat_detection_category(self):
bulk_checks_metatada = {
CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata()
}
categories = {"threat-detection"}
assert {CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME} == load_checks_to_execute(
bulk_checks_metadata=bulk_checks_metatada,
categories=categories,
provider=self.provider,
)
def test_discard_threat_detection_checks(self):
bulk_checks_metatada = {
CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata()
}
categories = {}
assert set() == load_checks_to_execute(
bulk_checks_metadata=bulk_checks_metatada,
categories=categories,
provider=self.provider,
)
+69
View File
@@ -32,6 +32,35 @@ mock_metadata = CheckMetadata(
Compliance=[],
)
mock_metadata_lambda = CheckMetadata(
Provider="aws",
CheckID="awslambda_function_url_public",
CheckTitle="Check 1",
CheckType=["type1"],
ServiceName="lambda",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="url1",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
Compliance=[],
)
class TestCheckMetada:
@@ -188,6 +217,46 @@ class TestCheckMetada:
# Assertions
assert result == {"accessanalyzer_enabled"}
@mock.patch("prowler.lib.check.models.load_check_metadata")
@mock.patch("prowler.lib.check.models.recover_checks_from_provider")
def test_list_by_service_lambda(self, mock_recover_checks, mock_load_metadata):
# Mock the return value of recover_checks_from_provider
mock_recover_checks.return_value = [
("awslambda_function_url_public", "/path/to/awslambda_function_url_public")
]
# Mock the return value of load_check_metadata
mock_load_metadata.return_value = mock_metadata_lambda
bulk_metadata = CheckMetadata.get_bulk(provider="aws")
result = CheckMetadata.list(
bulk_checks_metadata=bulk_metadata, service="lambda"
)
# Assertions
assert result == {"awslambda_function_url_public"}
@mock.patch("prowler.lib.check.models.load_check_metadata")
@mock.patch("prowler.lib.check.models.recover_checks_from_provider")
def test_list_by_service_awslambda(self, mock_recover_checks, mock_load_metadata):
# Mock the return value of recover_checks_from_provider
mock_recover_checks.return_value = [
("awslambda_function_url_public", "/path/to/awslambda_function_url_public")
]
# Mock the return value of load_check_metadata
mock_load_metadata.return_value = mock_metadata_lambda
bulk_metadata = CheckMetadata.get_bulk(provider="aws")
result = CheckMetadata.list(
bulk_checks_metadata=bulk_metadata, service="awslambda"
)
# Assertions
assert result == {"awslambda_function_url_public"}
@mock.patch("prowler.lib.check.models.load_check_metadata")
@mock.patch("prowler.lib.check.models.recover_checks_from_provider")
def test_list_by_service_invalid(self, mock_recover_checks, mock_load_metadata):
@@ -57,7 +57,7 @@ class Test_containerregistry_not_publicly_accessible:
resource_group="mock_resource_group",
sku="Basic",
login_server="mock_login_server.azurecr.io",
public_network_access="Enabled",
public_network_access=True,
admin_user_enabled=True,
network_rule_set=NetworkRuleSet(default_action="Allow"),
private_endpoint_connections=[],
@@ -131,7 +131,7 @@ class Test_containerregistry_not_publicly_accessible:
resource_group="mock_resource_group",
sku="Basic",
login_server="mock_login_server.azurecr.io",
public_network_access="Enabled",
public_network_access=False,
admin_user_enabled=False,
network_rule_set=NetworkRuleSet(default_action="Deny"),
private_endpoint_connections=[],
@@ -32,7 +32,7 @@ class TestContainerRegistryService:
resource_group="mock_resource_group",
sku="Basic",
login_server="mock_login_server.azurecr.io",
public_network_access="Enabled",
public_network_access=False,
admin_user_enabled=True,
network_rule_set=None,
private_endpoint_connections=[],
@@ -71,7 +71,7 @@ class TestContainerRegistryService:
assert registry_info.resource_group == "mock_resource_group"
assert registry_info.sku == "Basic"
assert registry_info.login_server == "mock_login_server.azurecr.io"
assert registry_info.public_network_access == "Enabled"
assert not registry_info.public_network_access
assert registry_info.admin_user_enabled is True
assert isinstance(registry_info.monitor_diagnostic_settings, list)
@@ -1,3 +1,4 @@
import datetime
from unittest import mock
from tests.providers.gcp.gcp_fixtures import (
@@ -239,7 +240,10 @@ class Test_kms_key_rotation_enabled:
project_id=GCP_PROJECT_ID,
key_ring=keyring.name,
location=keylocation.name,
next_rotation_time="2025-09-01T00:00:00Z",
# Next rotation time of now + 100 days
next_rotation_time=(
datetime.datetime.now() - datetime.timedelta(days=+100)
).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
members=["user:jane@example.com"],
)
]
@@ -296,7 +300,10 @@ class Test_kms_key_rotation_enabled:
project_id=GCP_PROJECT_ID,
key_ring=keyring.name,
location=keylocation.name,
next_rotation_time="2024-09-01T00:00:00Z",
# Next rotation time of now + 30 days
next_rotation_time=(
datetime.datetime.now() - datetime.timedelta(days=+30)
).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
members=["user:jane@example.com"],
)
]
@@ -352,7 +359,10 @@ class Test_kms_key_rotation_enabled:
id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
project_id=GCP_PROJECT_ID,
rotation_period="8776000s",
next_rotation_time="2025-09-01T00:00:00Z",
# Next rotation time of now + 100 days
next_rotation_time=(
datetime.datetime.now() - datetime.timedelta(days=+100)
).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
key_ring=keyring.name,
location=keylocation.name,
members=["user:jane@example.com"],
@@ -412,7 +422,10 @@ class Test_kms_key_rotation_enabled:
id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
project_id=GCP_PROJECT_ID,
rotation_period="8776000s",
next_rotation_time="2024-09-01T00:00:00Z",
# Next rotation time of now + 30 days
next_rotation_time=(
datetime.datetime.now() - datetime.timedelta(days=+30)
).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
key_ring=keyring.name,
location=keylocation.name,
members=["user:jane@example.com"],
@@ -470,7 +483,10 @@ class Test_kms_key_rotation_enabled:
id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
project_id=GCP_PROJECT_ID,
rotation_period="7776000s",
next_rotation_time="2025-09-01T00:00:00Z",
# Next rotation time of now + 100 days
next_rotation_time=(
datetime.datetime.now() - datetime.timedelta(days=+100)
).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
key_ring=keyring.name,
location=keylocation.name,
members=["user:jane@example.com"],
@@ -530,7 +546,10 @@ class Test_kms_key_rotation_enabled:
id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
project_id=GCP_PROJECT_ID,
rotation_period="7776000s",
next_rotation_time="2024-09-01T00:00:00Z",
# Next rotation time of now + 30 days
next_rotation_time=(
datetime.datetime.now() - datetime.timedelta(days=+30)
).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
key_ring=keyring.name,
location=keylocation.name,
members=["user:jane@example.com"],
@@ -588,7 +607,10 @@ class Test_kms_key_rotation_enabled:
id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
project_id=GCP_PROJECT_ID,
rotation_period="7776000s",
next_rotation_time="2025-07-06T22:00:00.561275Z",
# Next rotation time of now + 100 days
next_rotation_time=(
datetime.datetime.now() - datetime.timedelta(days=+100)
).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
key_ring=keyring.name,
location=keylocation.name,
members=["user:jane@example.com"],
+25 -12
View File
@@ -37,6 +37,10 @@ export async function authenticate(
credentials: "Incorrect email or password",
},
};
case "CallbackRouteError":
return {
message: error.cause?.err?.message,
};
default:
return {
message: "Unknown error",
@@ -152,22 +156,31 @@ export const getUserByMe = async (accessToken: string) => {
},
});
if (!response.ok) throw new Error("Error in trying to get user by me");
const parsedResponse = await response.json();
if (!response.ok) {
// Handle different HTTP error codes
switch (response.status) {
case 401:
throw new Error("Invalid or expired token");
case 403:
throw new Error(parsedResponse.errors?.[0]?.detail);
case 404:
throw new Error("User not found");
default:
throw new Error(
parsedResponse.errors?.[0]?.detail || "Unknown error",
);
}
}
const name = parsedResponse.data.attributes.name;
const email = parsedResponse.data.attributes.email;
const company = parsedResponse.data.attributes.company_name;
const dateJoined = parsedResponse.data.attributes.date_joined;
return {
name,
email,
company,
dateJoined,
name: parsedResponse.data.attributes.name,
email: parsedResponse.data.attributes.email,
company: parsedResponse.data.attributes.company_name,
dateJoined: parsedResponse.data.attributes.date_joined,
};
} catch (error) {
throw new Error("Error in trying to get user by me");
} catch (error: any) {
throw new Error(error.message || "Network error or server unreachable");
}
};
+47 -1
View File
@@ -8,6 +8,7 @@ import { parseStringify } from "@/lib";
export const getFindings = async ({
page = 1,
pageSize = 10,
query = "",
sort = "",
filters = {},
@@ -21,10 +22,11 @@ export const getFindings = async ({
const url = new URL(`${keyServer}/findings?include=resources.provider,scan`);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
@@ -43,7 +45,51 @@ export const getFindings = async ({
revalidatePath("/findings");
return parsedData;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching findings:", error);
return undefined;
}
};
export const getServicesRegions = async ({
query = "",
sort = "",
filters = {},
}) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/findings/findings_services_regions`);
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch services regions: ${response.statusText}`,
);
}
const data = await response.json();
const parsedData = parseStringify(data);
return parsedData;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching services regions:", error);
return undefined;
}
};
+19 -13
View File
@@ -102,6 +102,7 @@ export const updateProvider = async (formData: FormData) => {
revalidatePath("/providers");
return parseStringify(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
@@ -113,13 +114,24 @@ export const addProvider = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const providerType = formData.get("providerType");
const providerUid = formData.get("providerUid");
const providerAlias = formData.get("providerAlias");
const providerType = formData.get("providerType") as string;
const providerUid = formData.get("providerUid") as string;
const providerAlias = formData.get("providerAlias") as string;
const url = new URL(`${keyServer}/providers`);
try {
const bodyData = {
data: {
type: "providers",
attributes: {
provider: providerType,
uid: providerUid,
...(providerAlias?.trim() && { alias: providerAlias.trim() }),
},
},
};
const response = await fetch(url.toString(), {
method: "POST",
headers: {
@@ -127,21 +139,14 @@ export const addProvider = async (formData: FormData) => {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify({
data: {
type: "providers",
attributes: {
provider: providerType,
uid: providerUid,
alias: providerAlias,
},
},
}),
body: JSON.stringify(bodyData),
});
const data = await response.json();
revalidatePath("/providers");
return parseStringify(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
@@ -240,6 +245,7 @@ export const addCredentialsProvider = async (formData: FormData) => {
revalidatePath("/providers");
return parseStringify(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
+44
View File
@@ -110,6 +110,49 @@ export const scanOnDemand = async (formData: FormData) => {
revalidatePath("/scans");
return parseStringify(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
};
}
};
export const scheduleDaily = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const providerId = formData.get("providerId");
const url = new URL(`${keyServer}/schedules/daily`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify({
data: {
type: "daily-schedules",
attributes: {
provider_id: providerId,
},
},
}),
});
if (!response.ok) {
throw new Error(`Failed to schedule daily: ${response.statusText}`);
}
const data = await response.json();
revalidatePath("/scans");
return parseStringify(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
@@ -148,6 +191,7 @@ export const updateScan = async (formData: FormData) => {
revalidatePath("/scans");
return parseStringify(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
+28
View File
@@ -114,3 +114,31 @@ export const deleteUser = async (formData: FormData) => {
};
}
};
export const getProfileInfo = async () => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/users/me`);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch user data: ${response.statusText}`);
}
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/profile");
return parsedData;
} catch (error) {
console.error("Error fetching profile:", error);
return undefined;
}
};
+2
View File
@@ -140,6 +140,7 @@ const SSRComplianceGrid = async ({
const { attributes } = compliance;
const {
framework,
version,
requirements_status: { passed, total },
} = attributes;
@@ -147,6 +148,7 @@ const SSRComplianceGrid = async ({
<ComplianceCard
key={compliance.id}
title={framework}
version={version}
passingRequirements={passed}
totalRequirements={total}
prevPassingRequirements={passed}
+76 -53
View File
@@ -1,7 +1,7 @@
import { Spacer } from "@nextui-org/react";
import React, { Suspense } from "react";
import { getFindings } from "@/actions/findings";
import { getFindings, getServicesRegions } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { filterFindings } from "@/components/filters/data-filters";
@@ -13,7 +13,12 @@ import {
import { Header } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { createDict } from "@/lib";
import { FindingProps, SearchParamsProps } from "@/types/components";
import {
FindingProps,
ProviderProps,
ScanProps,
SearchParamsProps,
} from "@/types/components";
export default async function Findings({
searchParams,
@@ -21,66 +26,67 @@ export default async function Findings({
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
const defaultSort = "severity,status";
const sort = searchParams.sort?.toString() || defaultSort;
// Make sure the sort is correctly encoded
const encodedSort = sort.replace(/^\+/, "");
// Extract all filter parameters and combine with default filters
const defaultFilters = {
"filter[status__in]": "FAIL, PASS",
"filter[delta__in]": "new",
};
const filters: Record<string, string> = {
...defaultFilters,
...Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
),
};
const query = filters["filter[search]"] || "";
const servicesRegionsData = await getServicesRegions({
query,
sort: encodedSort,
filters,
});
// Extract unique regions and services from the new endpoint
const uniqueRegions = servicesRegionsData?.data?.attributes?.regions || [];
const uniqueServices = servicesRegionsData?.data?.attributes?.services || [];
// Get findings data
const findingsData = await getFindings({});
const providersData = await getProviders({});
const scansData = await getScans({});
// Extract provider UIDs
const providerUIDs = providersData?.data
?.map((provider: any) => provider.attributes.uid)
.filter(Boolean);
const providerUIDs = Array.from(
new Set(
providersData?.data
?.map((provider: ProviderProps) => provider.attributes.uid)
.filter(Boolean),
),
);
// Extract scan UUIDs with "completed" state and more than one resource
const completedScans = scansData?.data
?.filter(
(scan: any) =>
scan.attributes.state === "completed" &&
scan.attributes.unique_resource_count > 1 &&
scan.attributes.name, // Ensure it has a name
scan.attributes.unique_resource_count > 1,
)
.map((scan: any) => ({
.map((scan: ScanProps) => ({
id: scan.id,
name: scan.attributes.name,
}));
const completedScanIds = completedScans?.map((scan: any) => scan.id) || [];
// Create resource dictionary
const resourceDict = createDict("resources", findingsData);
// Get unique regions and services
const allRegionsAndServices =
findingsData?.data
?.flatMap((finding: FindingProps) => {
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
return {
region: resource?.attributes?.region,
service: resource?.attributes?.service,
};
})
.filter(Boolean) || [];
const uniqueRegions = Array.from(
new Set<string>(
allRegionsAndServices
.map((item: { region: string }) => item.region)
.filter(Boolean) || [],
),
);
const uniqueServices = Array.from(
new Set<string>(
allRegionsAndServices
.map((item: { service: string }) => item.service)
.filter(Boolean) || [],
),
);
const completedScanIds =
completedScans?.map((scan: ScanProps) => scan.id) || [];
return (
<>
<Header title="Findings" icon="ph:list-checks-duotone" />
<Header title="Findings" icon="carbon:data-view-alt" />
<Spacer />
<Spacer y={4} />
<FilterControls search date />
@@ -100,13 +106,13 @@ export default async function Findings({
},
{
key: "provider_uid__in",
labelCheckboxGroup: "Account",
labelCheckboxGroup: "Provider UID",
values: providerUIDs,
},
{
key: "scan__in",
labelCheckboxGroup: "Scans",
values: completedScanIds, // Use UUIDs in the filter
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
},
]}
defaultOpen={true}
@@ -125,17 +131,34 @@ const SSRDataTable = async ({
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const sort = searchParams.sort?.toString();
const defaultSort = "severity,status";
const sort = searchParams.sort?.toString() || defaultSort;
// Extract all filter parameters
const filters = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
);
// Make sure the sort is correctly encoded
const encodedSort = sort.replace(/^\+/, "");
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
// Extract all filter parameters and combine with default filters
const defaultFilters = {
"filter[status__in]": "FAIL, PASS",
"filter[delta__in]": "new",
};
const findingsData = await getFindings({ query, page, sort, filters });
const filters: Record<string, string> = {
...defaultFilters,
...Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
),
};
const query = filters["filter[search]"] || "";
const findingsData = await getFindings({
query,
page,
sort: encodedSort,
filters,
pageSize: 10,
});
// Create dictionaries for resources, scans, and providers
const resourceDict = createDict("resources", findingsData);
+65 -60
View File
@@ -21,7 +21,8 @@ import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-tabl
import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table/skeleton-table-new-findings";
import { Header } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
import { SearchParamsProps } from "@/types";
import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types";
export default function Home({
searchParams,
@@ -33,38 +34,35 @@ export default function Home({
<>
<Header title="Scan Overview" icon="solar:pie-chart-2-outline" />
<Spacer y={4} />
<FilterControls providers regions date />
<div className="min-h-screen">
<div className="container mx-auto space-y-8 px-0 py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-3">
<Suspense fallback={<SkeletonProvidersOverview />}>
<SSRProvidersOverview />
</Suspense>
</div>
<FilterControls providers />
<div className="mx-auto space-y-8 px-0 py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonProvidersOverview />}>
<SSRProvidersOverview />
</Suspense>
</div>
<div className="col-span-12 space-y-6 lg:col-span-4">
<div>
<Suspense fallback={<SkeletonFindingsByStatusChart />}>
<SSRFindingsByStatus searchParams={searchParams} />
</Suspense>
</div>
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonFindingsBySeverityChart />}>
<SSRFindingsBySeverity searchParams={searchParams} />
</Suspense>
</div>
<div>
<Suspense fallback={<SkeletonFindingsBySeverityChart />}>
<SSRFindingsBySeverity searchParams={searchParams} />
</Suspense>
</div>
</div>
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonFindingsByStatusChart />}>
<SSRFindingsByStatus searchParams={searchParams} />
</Suspense>
</div>
<div className="col-span-12 lg:col-span-5">
<Suspense
key={searchParamsKey}
fallback={<SkeletonTableNewFindings />}
>
<SSRDataNewFindingsTable />
</Suspense>
</div>
<div className="col-span-12">
<Spacer y={16} />
<Suspense
key={searchParamsKey}
fallback={<SkeletonTableNewFindings />}
>
<SSRDataNewFindingsTable />
</Suspense>
</div>
</div>
</div>
@@ -77,7 +75,7 @@ const SSRProvidersOverview = async () => {
return (
<>
<h3 className="mb-4 text-sm font-bold">Providers Overview</h3>
<h3 className="mb-4 text-sm font-bold uppercase">Providers Overview</h3>
<ProvidersOverview providersOverview={providersOverview} />
</>
);
@@ -100,7 +98,7 @@ const SSRFindingsByStatus = async ({
return (
<>
<h3 className="mb-4 text-sm font-bold">Findings by Status</h3>
<h3 className="mb-4 text-sm font-bold uppercase">Findings by Status</h3>
<FindingsByStatusChart findingsByStatus={findingsByStatus} />
</>
);
@@ -123,53 +121,60 @@ const SSRFindingsBySeverity = async ({
return (
<>
<h3 className="mb-4 text-sm font-bold">Findings by Severity</h3>
<h3 className="mb-4 text-sm font-bold uppercase">Findings by Severity</h3>
<FindingsBySeverityChart findingsBySeverity={findingsBySeverity} />
</>
);
};
const SSRDataNewFindingsTable = async () => {
// Temporarily disabled search params handling
// const page = parseInt(searchParams.page?.toString() || "1", 10);
// const sort = searchParams.sort?.toString();
const page = 1;
const sort = undefined;
const sort = "severity,updated_at";
// Extract all filter parameters
// const filters = Object.fromEntries(
// Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
// );
// const defaultFilters =
// Object.keys(filters).length === 0
// ? {
const defaultFilters = {
"filter[delta__in]": "new",
"filter[status__in]": "FAIL",
"filter[delta__in]": "new",
};
// : {};
const finalFilters = {
...defaultFilters,
// ...filters, // Temporarily disabled additional filters
} as Record<string, string>;
// const query = finalFilters["filter[search]"];
const query = undefined;
const findingsData = await getFindings({
query,
query: undefined,
page,
sort,
filters: finalFilters,
filters: defaultFilters,
});
// Create dictionaries for resources, scans, and providers
const resourceDict = createDict("resources", findingsData);
const scanDict = createDict("scans", findingsData);
const providerDict = createDict("providers", findingsData);
// Expand each finding with its corresponding resource, scan, and provider
const expandedFindings = findingsData?.data
? findingsData.data.map((finding: FindingProps) => {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider =
providerDict[resource?.relationships?.provider?.data?.id];
return {
...finding,
relationships: { scan, resource, provider },
};
})
: [];
// Create the new object while maintaining the original structure
const expandedResponse = {
...findingsData,
data: expandedFindings,
};
return (
<>
<div className="relative flex items-start justify-between">
<h3 className="mb-4 w-full text-sm font-bold">
New failing findings to date
<h3 className="mb-4 w-full text-sm font-bold uppercase">
Latest 10 failing findings to date by Severity
</h3>
<div className="absolute -top-6 right-0">
<LinkToFindings />
@@ -177,7 +182,7 @@ const SSRDataNewFindingsTable = async () => {
</div>
<DataTable
columns={ColumnNewFindingsToDate}
data={findingsData?.data || []}
data={expandedResponse?.data || []}
// metadata={findingsData?.meta}
/>
</>
+27 -18
View File
@@ -1,30 +1,39 @@
import { Spacer } from "@nextui-org/react";
import { redirect } from "next/navigation";
import React from "react";
import React, { Suspense } from "react";
// import { getUserByMe } from "@/actions/auth/auth";
import { auth } from "@/auth.config";
import { getProfileInfo } from "@/actions/users/users";
import { Header } from "@/components/ui";
import { SkeletonUserInfo } from "@/components/users/profile";
import { UserInfo } from "@/components/users/profile/user-info";
import { UserProfileProps } from "@/types";
export default async function Profile() {
const session = await auth();
if (!session?.user) {
// redirect("/sign-in?returnTo=/profile");
redirect("/sign-in");
}
// const user = await getUserByMe();
return (
<>
<Header title="User Profile" icon="ci:users" />
<Spacer y={4} />
<Spacer y={6} />
<pre>{JSON.stringify(session.user, null, 2)}</pre>
<pre>{JSON.stringify(session.userId, null, 2)}</pre>
<pre>{JSON.stringify(session.tenantId, null, 2)}</pre>
<pre>{JSON.stringify(session, null, 2)}</pre>
<div className="min-h-screen">
<div className="container mx-auto space-y-8 px-0 py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-3">
<Suspense fallback={<SkeletonUserInfo />}>
<SSRDataUser />
</Suspense>
</div>
</div>
</div>
</div>
</>
);
}
const SSRDataUser = async () => {
const userProfile: UserProfileProps = await getProfileInfo();
return (
<>
<h3 className="mb-4 text-sm font-bold">User Info</h3>
<UserInfo user={userProfile?.data} />
</>
);
};
+7 -3
View File
@@ -31,9 +31,13 @@ export default async function Providers({
<DataTableFilterCustom filters={filterProviders || []} />
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableProviders />}>
<SSRDataTable searchParams={searchParams} />
</Suspense>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<Suspense key={searchParamsKey} fallback={<SkeletonTableProviders />}>
<SSRDataTable searchParams={searchParams} />
</Suspense>
</div>
</div>
</>
);
}
+93 -44
View File
@@ -1,10 +1,14 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getProvider, getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { filterScans } from "@/components/filters";
import { ButtonRefreshData } from "@/components/scans";
import {
ButtonRefreshData,
NoProvidersAdded,
NoProvidersConnected,
} from "@/components/scans";
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
import { SkeletonTableScans } from "@/components/scans/table";
import { ColumnGetScans } from "@/components/scans/table/scans";
@@ -21,46 +25,73 @@ export default async function Scans({
delete filteredParams.scanId;
const searchParamsKey = JSON.stringify(filteredParams);
const providersData = await getProviders({});
const providersData = await getProviders({
filters: {
"filter[connected]": true,
},
});
const providerInfo = providersData?.data?.length
? providersData.data.map((provider: ProviderProps) => ({
providerId: provider.id,
alias: provider.attributes.alias,
providerType: provider.attributes.provider,
uid: provider.attributes.uid,
connected: provider.attributes.connection.connected,
}))
: [];
const providerInfo =
providersData?.data.map((provider: ProviderProps) => ({
providerId: provider.id,
alias: provider.attributes.alias,
providerType: provider.attributes.provider,
uid: provider.attributes.uid,
connected: provider.attributes.connection.connected,
})) || [];
// const executingScans = await getExecutingScans();
const providersCountConnected = await getProviders({});
const thereIsNoProviders =
!providersCountConnected?.data || providersCountConnected.data.length === 0;
const thereIsNoProvidersConnected = providersCountConnected?.data?.every(
(provider: ProviderProps) => !provider.attributes.connection.connected,
);
return (
<>
<Header title="Scans" icon="lucide:scan-search" />
<Spacer y={4} />
<LaunchScanWorkflow providers={providerInfo} />
<Spacer y={8} />
<div className="flex flex-row justify-between">
<DataTableFilterCustom filters={filterScans || []} />
<ButtonRefreshData
onPress={async () => {
"use server";
await getScans({});
}}
/>
</div>
{thereIsNoProviders && (
<>
<Spacer y={4} />
<NoProvidersAdded />
</>
)}
<Spacer y={8} />
<div className="grid grid-cols-12 items-start gap-4">
<div className="col-span-12">
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={searchParams} />
</Suspense>
</div>
</div>
{!thereIsNoProviders && (
<>
{thereIsNoProvidersConnected ? (
<>
<Spacer y={8} />
<NoProvidersConnected />
</>
) : (
<>
<LaunchScanWorkflow providers={providerInfo} />
<Spacer y={8} />
</>
)}
<Spacer y={8} />
<div className="grid grid-cols-12 items-start gap-4">
<div className="col-span-12">
<div className="flex flex-row items-center justify-between">
<DataTableFilterCustom filters={filterScans || []} />
<ButtonRefreshData
onPress={async () => {
"use server";
await getScans({});
}}
/>
</div>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={searchParams} />
</Suspense>
</div>
</div>
</>
)}
</>
);
}
@@ -83,22 +114,40 @@ const SSRDataTableScans = async ({
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
// Fetch scans data
const scansData = await getScans({ query, page, sort, filters });
// Handle expanded scans data
const expandedScansData = await Promise.all(
scansData?.data?.map(async (scan: any) => {
const providerId = scan.relationships?.provider?.data?.id;
if (!providerId) {
return { ...scan, providerInfo: null };
}
const formData = new FormData();
formData.append("id", providerId);
const providerData = await getProvider(formData);
if (providerData?.data) {
const { provider, uid, alias } = providerData.data.attributes;
return {
...scan,
providerInfo: { provider, uid, alias },
};
}
return { ...scan, providerInfo: null };
}) || [],
);
return (
<DataTable
columns={ColumnGetScans}
data={scansData?.data || []}
data={expandedScansData || []}
metadata={scansData?.meta}
/>
);
};
// const getExecutingScans = async () => {
// const scansData = await getScans({});
// return scansData?.data?.some(
// (scan: ScanProps) =>
// scan.attributes.state === "executing" && scan.attributes.progress < 100,
// );
// };
+1
View File
@@ -105,6 +105,7 @@ export const authConfig = {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith("/");
const isSignUpPage = nextUrl.pathname === "/sign-up";
//CLOUD API CHANGES
// Allow access to sign-up page
if (isSignUpPage) return true;
+13 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Checkbox, Link } from "@nextui-org/react";
import { Link } from "@nextui-org/react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -17,9 +17,11 @@ import { ApiError, authFormSchema } from "@/types";
export const AuthForm = ({
type,
invitationToken,
isCloudEnv,
}: {
type: string;
invitationToken?: string | null;
isCloudEnv?: boolean;
}) => {
const formSchema = authFormSchema(type);
const router = useRouter();
@@ -47,7 +49,6 @@ export const AuthForm = ({
email: data.email.toLowerCase(),
password: data.password,
});
if (result?.message === "Success") {
router.push("/");
} else if (result?.errors && "credentials" in result.errors) {
@@ -55,6 +56,8 @@ export const AuthForm = ({
type: "server",
message: result.errors.credentials ?? "Incorrect email or password",
});
} else if (result?.message === "User email is not verified") {
router.push("/email-verification");
} else {
toast({
variant: "destructive",
@@ -73,7 +76,12 @@ export const AuthForm = ({
description: "The user was registered successfully.",
});
form.reset();
router.push("/sign-in");
if (isCloudEnv) {
router.push("/email-verification");
} else {
router.push("/sign-in");
}
} else {
newUser.errors.forEach((error: ApiError) => {
const errorMessage = error.detail;
@@ -175,7 +183,7 @@ export const AuthForm = ({
isInvalid={!!form.formState.errors.password}
/>
{type === "sign-in" && (
{/* {type === "sign-in" && (
<div className="flex items-center justify-between px-1 py-2">
<Checkbox name="remember" size="sm">
Remember me
@@ -184,7 +192,7 @@ export const AuthForm = ({
Forgot password?
</Link>
</div>
)}
)} */}
{type === "sign-up" && (
<>
<CustomInput
+11 -2
View File
@@ -6,6 +6,7 @@ import { getComplianceIcon } from "../icons";
interface ComplianceCardProps {
title: string;
version: string;
passingRequirements: number;
totalRequirements: number;
prevPassingRequirements: number;
@@ -14,9 +15,14 @@ interface ComplianceCardProps {
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
title,
version,
passingRequirements,
totalRequirements,
}) => {
const formatTitle = (title: string) => {
return title.split("-").join(" ");
};
const ratingPercentage = Math.floor(
(passingRequirements / totalRequirements) * 100,
);
@@ -47,7 +53,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
};
return (
<Card fullWidth isPressable isHoverable shadow="sm">
<Card fullWidth isHoverable shadow="sm">
<CardBody className="flex flex-row items-center justify-between space-x-4 dark:bg-prowler-blue-800">
<div className="flex w-full items-center space-x-4">
<Image
@@ -56,7 +62,10 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
className="h-10 w-10 min-w-10 rounded-md border-1 border-gray-300 bg-white object-contain p-1"
/>
<div className="flex w-full flex-col">
<h4 className="text-md font-bold leading-5 3xl:text-lg">{title}</h4>
<h4 className="text-md font-bold leading-5 3xl:text-lg">
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</h4>
<Progress
label="Your Rating:"
size="sm"
@@ -17,7 +17,15 @@ export const DataCompliance = ({ scans, regions }: DataComplianceProps) => {
const searchParams = useSearchParams();
const [showClearButton, setShowClearButton] = useState(false);
const scanIdParam = searchParams.get("scanId");
const selectedScanId = scanIdParam || scans[0]?.id;
const selectedScanId = scanIdParam || (scans.length > 0 ? scans[0].id : "");
useEffect(() => {
if (!scanIdParam && scans.length > 0) {
const params = new URLSearchParams(searchParams);
params.set("scanId", scans[0].id);
router.push(`?${params.toString()}`);
}
}, [scans, scanIdParam, searchParams, router]);
useEffect(() => {
const hasFilters = Array.from(searchParams.keys()).some(
+6 -5
View File
@@ -14,9 +14,11 @@ import React, { useCallback, useEffect, useRef } from "react";
export const CustomDatePicker = () => {
const router = useRouter();
const searchParams = useSearchParams();
const defaultDate = today(getLocalTimeZone());
const [value, setValue] = React.useState(defaultDate);
const [value, setValue] = React.useState(() => {
const dateParam = searchParams.get("filter[updated_at]");
return dateParam ? today(getLocalTimeZone()) : null;
});
const { locale } = useLocale();
@@ -46,8 +48,7 @@ export const CustomDatePicker = () => {
}
const params = new URLSearchParams(searchParams.toString());
if (params.size === 0) {
// If all params are cleared, reset to default date
setValue(defaultDate);
setValue(null);
}
}, [searchParams]);
@@ -80,7 +81,7 @@ export const CustomDatePicker = () => {
</ButtonGroup>
}
calendarProps={{
focusedValue: value,
focusedValue: value || undefined,
onFocusChange: setValue,
nextButtonProps: {
variant: "bordered",
@@ -4,37 +4,7 @@ import { Select, SelectItem } from "@nextui-org/react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useMemo } from "react";
const regions = [
{ key: "af-south-1", label: "AF South 1" },
{ key: "ap-east-1", label: "AP East 1" },
{ key: "ap-northeast-1", label: "AP Northeast 1" },
{ key: "ap-northeast-2", label: "AP Northeast 2" },
{ key: "ap-northeast-3", label: "AP Northeast 3" },
{ key: "ap-south-1", label: "AP South 1" },
{ key: "ap-south-2", label: "AP South 2" },
{ key: "ap-southeast-1", label: "AP Southeast 1" },
{ key: "ap-southeast-2", label: "AP Southeast 2" },
{ key: "ap-southeast-3", label: "AP Southeast 3" },
{ key: "ap-southeast-4", label: "AP Southeast 4" },
{ key: "ca-central-1", label: "CA Central 1" },
{ key: "ca-west-1", label: "CA West 1" },
{ key: "eu-central-1", label: "EU Central 1" },
{ key: "eu-central-2", label: "EU Central 2" },
{ key: "eu-north-1", label: "EU North 1" },
{ key: "eu-south-1", label: "EU South 1" },
{ key: "eu-south-2", label: "EU South 2" },
{ key: "eu-west-1", label: "EU West 1" },
{ key: "eu-west-2", label: "EU West 2" },
{ key: "eu-west-3", label: "EU West 3" },
{ key: "il-central-1", label: "IL Central 1" },
{ key: "me-central-1", label: "ME Central 1" },
{ key: "me-south-1", label: "ME South 1" },
{ key: "sa-east-1", label: "SA East 1" },
{ key: "us-east-1", label: "US East 1" },
{ key: "us-east-2", label: "US East 2" },
{ key: "us-west-1", label: "US West 1" },
{ key: "us-west-2", label: "US West 2" },
];
import { regions } from "@/lib/helper";
export const CustomRegionSelection: React.FC = () => {
const router = useRouter();
+2 -2
View File
@@ -10,7 +10,7 @@ export const filterProviders = [
export const filterScans = [
{
key: "provider_type__in",
labelCheckboxGroup: "Provider",
labelCheckboxGroup: "Cloud Provider",
values: ["aws", "azure", "gcp", "kubernetes"],
},
{
@@ -51,7 +51,7 @@ export const filterFindings = [
},
{
key: "provider_type__in",
labelCheckboxGroup: "Provider",
labelCheckboxGroup: "Cloud Provider",
values: ["aws", "azure", "gcp", "kubernetes"],
},
// Add more filter categories as needed
+2 -2
View File
@@ -61,11 +61,11 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
className="w-full md:w-fit"
onPress={clearAllFilters}
variant="dashed"
size="sm"
size="md"
endContent={<CrossIcon size={24} />}
radius="sm"
>
Reset
Clear all filters
</CustomButton>
)}
</div>
@@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation";
import { DataTableRowDetails } from "@/components/findings/table";
import { InfoIcon } from "@/components/icons";
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import {
DataTableColumnHeader,
@@ -13,8 +14,6 @@ import {
} from "@/components/ui/table";
import { FindingProps } from "@/types";
import { DataTableRowActions } from "./data-table-row-actions";
const getFindingsData = (row: { original: FindingProps }) => {
return row.original;
};
@@ -43,25 +42,57 @@ const getProviderData = (
);
};
const getScanData = (
row: { original: FindingProps },
field: keyof FindingProps["relationships"]["scan"]["attributes"],
) => {
return (
row.original.relationships?.scan?.attributes?.[field] ||
`No ${field} found in scan`
);
};
// const getScanData = (
// row: { original: FindingProps },
// field: keyof FindingProps["relationships"]["scan"]["attributes"],
// ) => {
// return (
// row.original.relationships?.scan?.attributes?.[field] ||
// `No ${field} found in scan`
// );
// };
export const ColumnFindings: ColumnDef<FindingProps>[] = [
{
id: "moreInfo",
header: "Details",
cell: ({ row }) => {
const searchParams = useSearchParams();
const findingId = searchParams.get("id");
const isOpen = findingId === row.original.id;
return (
<div className="flex justify-center">
<TriggerSheet
triggerComponent={<InfoIcon className="text-primary" size={16} />}
title="Finding Details"
description="View the finding details"
defaultOpen={isOpen}
>
<DataTableRowDetails
entityId={row.original.id}
findingDetails={row.original}
/>
</TriggerSheet>
</div>
);
},
},
{
accessorKey: "check",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Check"} param="check_id" />
<DataTableColumnHeader
column={column}
title={"Finding"}
param="check_id"
/>
),
cell: ({ row }) => {
const { checktitle } = getFindingsMetadata(row);
return <p className="max-w-96 truncate text-small">{checktitle}</p>;
return (
<p className="max-w-[450px] whitespace-normal break-words text-small">
{checktitle}
</p>
);
},
},
{
@@ -94,20 +125,40 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
},
},
{
accessorKey: "scanName",
header: "Scan Name",
accessorKey: "updated_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Last seen"}
param="updated_at"
/>
),
cell: ({ row }) => {
const name = getScanData(row, "name");
const {
attributes: { updated_at },
} = getFindingsData(row);
return (
<p className="text-small">
{typeof name === "string" || typeof name === "number"
? name
: "Invalid data"}
</p>
<div className="w-[100px]">
<DateWithTime dateTime={updated_at} />
</div>
);
},
},
// {
// accessorKey: "scanName",
// header: "Scan Name",
// cell: ({ row }) => {
// const name = getScanData(row, "name");
// return (
// <p className="text-small">
// {typeof name === "string" || typeof name === "number"
// ? name
// : "Invalid data"}
// </p>
// );
// },
// },
{
accessorKey: "region",
header: "Region",
@@ -115,9 +166,9 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
const region = getResourceData(row, "region");
return (
<>
<div>{typeof region === "string" ? region : "Invalid region"}</div>
</>
<div className="w-[80px]">
{typeof region === "string" ? region : "Invalid region"}
</div>
);
},
},
@@ -130,48 +181,22 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
},
},
{
accessorKey: "account",
header: "Account",
accessorKey: "cloudProvider",
header: "Cloud provider",
cell: ({ row }) => {
const account = getProviderData(row, "uid");
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<>
<p className="max-w-96 truncate text-small">
{typeof account === "string" ? account : "Invalid account"}
</p>
<EntityInfoShort
cloudProvider={provider as "aws" | "azure" | "gcp" | "kubernetes"}
entityAlias={alias as string}
entityId={uid as string}
/>
</>
);
},
},
{
id: "moreInfo",
header: "Details",
cell: ({ row }) => {
const searchParams = useSearchParams();
const findingId = searchParams.get("id");
const isOpen = findingId === row.original.id;
return (
<div className="flex justify-center">
<TriggerSheet
triggerComponent={<InfoIcon className="text-primary" size={16} />}
title="Finding Details"
description="View the finding details"
defaultOpen={isOpen}
>
<DataTableRowDetails
entityId={row.original.id}
findingDetails={row.original}
/>
</TriggerSheet>
</div>
);
},
},
{
id: "actions",
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
},
];
@@ -48,7 +48,7 @@ export const FindingDetail = ({
<div className="flex flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-800 dark:text-prowler-theme-pale/90">
Check Metadata
Finding details
</h3>
<SeverityBadge severity={attributes.severity} />
</div>
@@ -99,7 +99,7 @@ export const FindingDetail = ({
Object.values(remediation.code).some(Boolean) && (
<div className="flex flex-col gap-2">
<p className="mt-4 text-sm font-semibold">
Check these links:
Reference Information:
</p>
<div className="flex flex-col gap-2">
{remediation.code.cli && (
@@ -201,13 +201,13 @@ export const FindingDetail = ({
<div className="col-span-2 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Inserted At
First seen
</p>
<DateWithTime inline dateTime={resource.inserted_at} />
</div>
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Updated At
Last seen
</p>
<DateWithTime inline dateTime={resource.updated_at} />
</div>
@@ -4,33 +4,47 @@ import { Card, CardBody } from "@nextui-org/react";
import { Bar, BarChart, LabelList, XAxis, YAxis } from "recharts";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart/Chart";
import { FindingsSeverityOverview } from "@/types/components";
export interface ChartConfig {
[key: string]: {
label?: React.ReactNode;
icon?: React.ComponentType<object>;
color?: string;
theme?: string;
link?: string;
};
}
const chartConfig = {
critical: {
label: "Critical",
color: "hsl(var(--chart-critical))",
link: "/findings?filter%5Bseverity__in%5D=critical",
},
high: {
label: "High",
color: "hsl(var(--chart-fail))",
link: "/findings?filter%5Bseverity__in%5D=high",
},
medium: {
label: "Medium",
color: "hsl(var(--chart-medium))",
link: "/findings?filter%5Bseverity__in%5D=medium",
},
low: {
label: "Low",
color: "hsl(var(--chart-low))",
link: "/findings?filter%5Bseverity__in%5D=low",
},
informational: {
label: "Informational",
color: "hsl(var(--chart-informational))",
link: "/findings?filter%5Bseverity__in%5D=informational",
},
} satisfies ChartConfig;
@@ -56,7 +70,7 @@ export const FindingsBySeverityChart = ({
}));
return (
<Card className="dark:bg-prowler-blue-400">
<Card className="h-full dark:bg-prowler-blue-400">
<CardBody>
<div className="my-auto">
<ChartContainer config={chartConfig}>
@@ -91,6 +105,14 @@ export const FindingsBySeverityChart = ({
layout="vertical"
radius={12}
barSize={20}
onClick={(data) => {
const severity = data.severity as keyof typeof chartConfig;
const link = chartConfig[severity]?.link;
if (link) {
window.location.href = link;
}
}}
style={{ cursor: "pointer" }}
>
<LabelList
position="insideRight"
@@ -3,6 +3,7 @@
import { Card, CardBody } from "@nextui-org/react";
import { Chip } from "@nextui-org/react";
import { TrendingUp } from "lucide-react";
import Link from "next/link";
import React, { useMemo } from "react";
import { Label, Pie, PieChart } from "recharts";
@@ -91,12 +92,12 @@ export const FindingsByStatusChart: React.FC<FindingsByStatusChartProps> = ({
];
return (
<Card className="dark:bg-prowler-blue-400">
<Card className="h-full dark:bg-prowler-blue-400">
<CardBody>
<div className="flex items-center gap-6">
<div className="flex flex-col items-center gap-6">
<ChartContainer
config={chartConfig}
className="aspect-square w-[150px] min-w-[150px]"
className="aspect-square w-[250px] min-w-[250px]"
>
<PieChart>
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
@@ -104,8 +105,8 @@ export const FindingsByStatusChart: React.FC<FindingsByStatusChartProps> = ({
data={totalFindings > 0 ? chartData : emptyChartData}
dataKey="number"
nameKey="findings"
innerRadius={40}
strokeWidth={35}
innerRadius={65}
strokeWidth={55}
>
<Label
content={({ viewBox }) => {
@@ -141,20 +142,26 @@ export const FindingsByStatusChart: React.FC<FindingsByStatusChartProps> = ({
</Pie>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-4">
<div className="grid w-full grid-cols-2 justify-items-center gap-6">
<div className="flex flex-col gap-2">
<div className="flex items-center space-x-2">
<Chip
className="h-5"
variant="flat"
startContent={<SuccessIcon size={18} />}
color="success"
radius="lg"
size="md"
<div className="flex items-center space-x-2 self-end">
<Link
href="/findings?filter[status]=PASS"
className="flex items-center space-x-2"
>
{chartData[0].number}
</Chip>
<span>{updatedChartData[0].percent}</span>
<Chip
className="h-5"
variant="flat"
startContent={<SuccessIcon size={18} />}
color="success"
radius="lg"
size="md"
>
{chartData[0].number}
</Chip>
<span>{updatedChartData[0].percent}</span>
</Link>
</div>
<div className="text-muted-foreground flex items-center gap-1 text-xs font-medium leading-none">
{pass_new > 0 ? (
@@ -169,19 +176,25 @@ export const FindingsByStatusChart: React.FC<FindingsByStatusChartProps> = ({
)}
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center space-x-2">
<Chip
className="h-5"
variant="flat"
startContent={<NotificationIcon size={18} />}
color="danger"
radius="lg"
size="md"
<div className="flex items-center align-middle">
<Link
href="/findings?filter[status]=FAIL"
className="flex items-center space-x-2"
>
{chartData[1].number}
</Chip>
<span>{updatedChartData[1].percent}</span>
<Chip
className="h-5"
variant="flat"
startContent={<NotificationIcon size={18} />}
color="danger"
radius="lg"
size="md"
>
{chartData[1].number}
</Chip>
<span>{updatedChartData[1].percent}</span>
</Link>
</div>
<div className="text-muted-foreground flex items-center gap-1 text-xs font-medium leading-none">
+{fail_new} fail findings from last day{" "}
@@ -1,20 +1,18 @@
"use client";
import { AddIcon } from "@/components/icons";
import { CustomButton } from "@/components/ui/custom";
export const LinkToFindings = () => {
return (
<div className="mt-4 flex w-full items-center justify-end">
<CustomButton
asLink="/findings?filter[delta__in]=new&filter[status__in]=FAIL"
asLink="/findings?sort=severity,-updated_at&filter[status__in]=FAIL"
ariaLabel="Go to Findings page"
variant="solid"
color="action"
size="sm"
endContent={<AddIcon size={20} />}
>
Check out on findings
Check out on Findings
</CustomButton>
</div>
);
@@ -1,8 +1,13 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { useSearchParams } from "next/navigation";
import { DataTableColumnHeader, SeverityBadge } from "@/components/ui/table";
import { DataTableRowDetails } from "@/components/findings/table";
import { InfoIcon } from "@/components/icons";
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import { FindingProps } from "@/types";
const getFindingsData = (row: { original: FindingProps }) => {
@@ -13,26 +18,66 @@ const getFindingsMetadata = (row: { original: FindingProps }) => {
return row.original.attributes.check_metadata;
};
const getResourceData = (
row: { original: FindingProps },
field: keyof FindingProps["relationships"]["resource"]["attributes"],
) => {
return (
row.original.relationships?.resource?.attributes?.[field] ||
`No ${field} found in resource`
);
};
const getProviderData = (
row: { original: FindingProps },
field: keyof FindingProps["relationships"]["provider"]["attributes"],
) => {
return (
row.original.relationships?.provider?.attributes?.[field] ||
`No ${field} found in provider`
);
};
export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
{
id: "moreInfo",
header: "Details",
cell: ({ row }) => {
const searchParams = useSearchParams();
const findingId = searchParams.get("id");
const isOpen = findingId === row.original.id;
return (
<div className="flex justify-center">
<TriggerSheet
triggerComponent={<InfoIcon className="text-primary" size={16} />}
title="Finding Details"
description="View the finding details"
defaultOpen={isOpen}
>
<DataTableRowDetails
entityId={row.original.id}
findingDetails={row.original}
/>
</TriggerSheet>
</div>
);
},
},
{
accessorKey: "check",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Check"} param="check_id" />
),
header: "Finding",
cell: ({ row }) => {
const { checktitle } = getFindingsMetadata(row);
return <p className="max-w-80 truncate text-small">{checktitle}</p>;
return (
<p className="max-w-[450px] whitespace-normal break-words text-small">
{checktitle}
</p>
);
},
},
{
accessorKey: "severity",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Severity"}
param="severity"
/>
),
header: "Severity",
cell: ({ row }) => {
const {
attributes: { severity },
@@ -40,4 +85,69 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
return <SeverityBadge severity={severity} />;
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const {
attributes: { status },
} = getFindingsData(row);
return <StatusFindingBadge size="sm" status={status} />;
},
},
{
accessorKey: "updated_at",
header: "Last seen",
cell: ({ row }) => {
const {
attributes: { updated_at },
} = getFindingsData(row);
return (
<div className="w-[100px]">
<DateWithTime dateTime={updated_at} />
</div>
);
},
},
{
accessorKey: "region",
header: "Region",
cell: ({ row }) => {
const region = getResourceData(row, "region");
return (
<div className="w-[80px]">
{typeof region === "string" ? region : "Invalid region"}
</div>
);
},
},
{
accessorKey: "service",
header: "Service",
cell: ({ row }) => {
const { servicename } = getFindingsMetadata(row);
return <p className="max-w-96 truncate text-small">{servicename}</p>;
},
},
{
accessorKey: "cloudProvider",
header: "Cloud provider",
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<>
<EntityInfoShort
cloudProvider={provider as "aws" | "azure" | "gcp" | "kubernetes"}
entityAlias={alias as string}
entityId={uid as string}
/>
</>
);
},
},
];
@@ -48,7 +48,7 @@ export const FindingDetail = ({
<div className="flex flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-800 dark:text-prowler-theme-pale/90">
Check Metadata
Finding details
</h3>
<SeverityBadge severity={attributes.severity} />
</div>
@@ -99,7 +99,7 @@ export const FindingDetail = ({
Object.values(remediation.code).some(Boolean) && (
<div className="flex flex-col gap-2">
<p className="mt-4 text-sm font-semibold">
Check these links:
Reference Information:
</p>
<div className="flex flex-col gap-2">
{remediation.code.cli && (
@@ -201,13 +201,13 @@ export const FindingDetail = ({
<div className="col-span-2 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Inserted At
First seen
</p>
<DateWithTime inline dateTime={resource.inserted_at} />
</div>
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Updated At
Last seen
</p>
<DateWithTime inline dateTime={resource.updated_at} />
</div>
@@ -44,9 +44,9 @@ export const ProvidersOverview = ({
if (!providersOverview || !Array.isArray(providersOverview.data)) {
return (
<Card className="dark:bg-prowler-blue-400">
<Card className="h-full dark:bg-prowler-blue-400">
<CardBody>
<div className="grid grid-cols-1 gap-3">
<div className="my-auto grid grid-cols-1 gap-3">
<div className="grid grid-cols-4 border-b pb-2 text-xs font-semibold">
<span className="text-center">Provider</span>
<span className="flex flex-col items-center text-center">
@@ -92,9 +92,9 @@ export const ProvidersOverview = ({
}
return (
<Card className="dark:bg-prowler-blue-400">
<Card className="h-full dark:bg-prowler-blue-400">
<CardBody>
<div className="grid grid-cols-1 gap-3">
<div className="my-auto grid grid-cols-1 gap-3">
<div className="grid grid-cols-4 border-b pb-2 text-xs font-semibold">
<span className="text-center">Provider</span>
<span className="flex flex-col items-center text-center">
@@ -175,7 +175,7 @@ export const ProvidersOverview = ({
</div>
<div className="mt-4 flex w-full items-center justify-end">
<CustomButton
asLink="/providers/connect-account"
asLink="/providers"
ariaLabel="Go to Providers page"
variant="solid"
color="action"
+1
View File
@@ -1,4 +1,5 @@
export * from "./add-provider";
export * from "./forms/delete-form";
export * from "./link-to-scans";
export * from "./provider-info";
export * from "./radio-group-provider";
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { CustomButton } from "@/components/ui/custom";
interface LinkToScansProps {
providerUid?: string;
}
export const LinkToScans = ({ providerUid }: LinkToScansProps) => {
return (
<CustomButton
asLink={`/scans?filter[provider_uid]=${providerUid}`}
ariaLabel="Go to Scans page"
variant="solid"
color="action"
size="sm"
>
View Scan Jobs
</CustomButton>
);
};
+15 -30
View File
@@ -1,23 +1,20 @@
import React from "react";
import { ConnectionFalse, ConnectionPending, ConnectionTrue } from "../icons";
import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
KS8ProviderBadge,
} from "../icons/providers-badge";
import { getProviderLogo } from "../ui/entities";
interface ProviderInfoProps {
connected: boolean | null;
provider: "aws" | "azure" | "gcp" | "kubernetes";
providerAlias: string;
providerUID?: string;
}
export const ProviderInfo: React.FC<ProviderInfoProps> = ({
connected,
provider,
providerAlias,
providerUID,
}) => {
const getIcon = () => {
switch (connected) {
@@ -44,32 +41,20 @@ export const ProviderInfo: React.FC<ProviderInfoProps> = ({
}
};
const getProviderLogo = () => {
switch (provider) {
case "aws":
return <AWSProviderBadge width={35} height={35} />;
case "azure":
return <AzureProviderBadge width={35} height={35} />;
case "gcp":
return <GCPProviderBadge width={35} height={35} />;
case "kubernetes":
return <KS8ProviderBadge width={35} height={35} />;
default:
return null;
}
};
return (
<div className="max-w-48">
<div className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">{getProviderLogo()}</div>
<div className="flex-shrink-0">{getIcon()}</div>
<div className="flex flex-col">
<span className="text-md max-w-24 overflow-hidden text-ellipsis font-semibold lg:max-w-36">
{providerAlias}
<div className="dark:bg-prowler-blue-400">
<div className="grid grid-cols-1">
<div className="flex items-center text-sm">
<div className="flex items-center">
<span className="flex items-center justify-center px-4">
{getProviderLogo(provider)}
</span>
{/* <CustomLoader size="small" /> */}
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">{getIcon()}</div>
<span className="font-medium">
{providerAlias || providerUID}
</span>
</div>
</div>
</div>
</div>
@@ -3,9 +3,10 @@
import { ColumnDef } from "@tanstack/react-table";
import { DateWithTime, SnippetId } from "@/components/ui/entities";
import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table";
import { DataTableColumnHeader } from "@/components/ui/table";
import { ProviderProps } from "@/types";
import { LinkToScans } from "../link-to-scans";
import { ProviderInfo } from "../provider-info";
import { DataTableRowActions } from "./data-table-row-actions";
@@ -14,10 +15,6 @@ const getProviderData = (row: { original: ProviderProps }) => {
};
export const ColumnProviders: ColumnDef<ProviderProps>[] = [
// {
// header: " ",
// cell: ({ row }) => <p className="text-medium">{row.index + 1}</p>,
// },
{
accessorKey: "account",
header: ({ column }) => (
@@ -25,17 +22,28 @@ export const ColumnProviders: ColumnDef<ProviderProps>[] = [
),
cell: ({ row }) => {
const {
attributes: { connection, provider, alias },
attributes: { connection, provider, alias, uid },
} = getProviderData(row);
return (
<ProviderInfo
connected={connection.connected}
provider={provider}
providerAlias={alias}
providerUID={uid}
/>
);
},
},
{
accessorKey: "scanJobs",
header: "Scan Jobs",
cell: ({ row }) => {
const {
attributes: { uid },
} = getProviderData(row);
return <LinkToScans providerUid={uid} />;
},
},
{
accessorKey: "uid",
header: ({ column }) => (
@@ -48,30 +56,6 @@ export const ColumnProviders: ColumnDef<ProviderProps>[] = [
return <SnippetId className="h-7 max-w-48" entityId={uid} />;
},
},
{
accessorKey: "status",
header: "Scan Status",
cell: () => {
// Temporarily overwriting the value until the API is functional.
return <StatusBadge status={"completed"} />;
},
},
{
accessorKey: "lastScan",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Last Scan"}
param="updated_at"
/>
),
cell: ({ row }) => {
const {
attributes: { updated_at },
} = getProviderData(row);
return <DateWithTime dateTime={updated_at} />;
},
},
{
accessorKey: "added",
header: ({ column }) => (

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