Merge branch 'master' of https://github.com/prowler-cloud/prowler into github-poc
@@ -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="
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
>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 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.
|
||||
|
||||

|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
After Width: | Height: | Size: 463 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 277 KiB |
@@ -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 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
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## Assigning the proper permissions
|
||||
|
||||
|
||||
@@ -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 you’ve 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 you’ve 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. Here’s 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"/>
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
// );
|
||||
// };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }) => (
|
||||
|
||||