diff --git a/.env b/.env new file mode 100644 index 0000000000..14317089d0 --- /dev/null +++ b/.env @@ -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=" diff --git a/.github/codeql/ui-codeql-config.yml b/.github/codeql/ui-codeql-config.yml new file mode 100644 index 0000000000..62ebee5617 --- /dev/null +++ b/.github/codeql/ui-codeql-config.yml @@ -0,0 +1,3 @@ +name: "Custom CodeQL Config for UI" +paths: + - "ui/" diff --git a/.github/workflows/api-pull-request.yml b/.github/workflows/api-pull-request.yml index ed145dd6d6..8143ca7f41 100644 --- a/.github/workflows/api-pull-request.yml +++ b/.github/workflows/api-pull-request.yml @@ -148,10 +148,9 @@ 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 - # TODO: Disabled until test_rbac.py is ready - # - name: Upload coverage reports to Codecov - # if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' - # uses: codecov/codecov-action@v5 - # env: - # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + 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 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/build-lint-push-containers.yml b/.github/workflows/build-lint-push-containers.yml index cd9872ff52..af17eeb193 100644 --- a/.github/workflows/build-lint-push-containers.yml +++ b/.github/workflows/build-lint-push-containers.yml @@ -38,6 +38,10 @@ env: # Python configuration PYTHON_VERSION: 3.12 + # Container Registries + PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud + PROWLERCLOUD_DOCKERHUB_IMAGE: prowler + jobs: # Build Prowler OSS container container-build-push: @@ -81,7 +85,13 @@ jobs: echo "STABLE_TAG=v3-stable" >> "${GITHUB_ENV}" ;; - 4) + + 4) + echo "LATEST_TAG=v4-latest" >> "${GITHUB_ENV}" + echo "STABLE_TAG=v4-stable" >> "${GITHUB_ENV}" + ;; + + 5) echo "LATEST_TAG=latest" >> "${GITHUB_ENV}" echo "STABLE_TAG=stable" >> "${GITHUB_ENV}" ;; @@ -119,6 +129,7 @@ jobs: tags: | ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }} ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }} + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} file: ${{ env.DOCKERFILE_PATH }} cache-from: type=gha cache-to: type=gha,mode=max @@ -136,6 +147,8 @@ jobs: ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.STABLE_TAG }} ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.PROWLER_VERSION }} ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.STABLE_TAG }} + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.PROWLER_VERSION }} + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} file: ${{ env.DOCKERFILE_PATH }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index cda4986493..2c8ae5e659 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -27,6 +27,9 @@ jobs: 4) echo "Releasing Prowler v4 with tag ${PROWLER_VERSION}" ;; + 5) + echo "Releasing Prowler v5 with tag ${PROWLER_VERSION}" + ;; *) echo "Releasing another Prowler major version, aborting..." exit 1 diff --git a/.github/workflows/ui-codeql.yml b/.github/workflows/ui-codeql.yml new file mode 100644 index 0000000000..2765921cf6 --- /dev/null +++ b/.github/workflows/ui-codeql.yml @@ -0,0 +1,61 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "UI - CodeQL" + +on: + push: + branches: + - "master" + - "v4.*" + - "v5.*" + paths: + - "ui/**" + pull_request: + branches: + - "master" + - "v4.*" + - "v5.*" + paths: + - "ui/**" + schedule: + - cron: "00 12 * * *" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["javascript"] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/ui-codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore index f115325102..5ed64d4658 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f871bf6de1..2fcb4d4c6f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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' diff --git a/README.md b/README.md index 3f1ed03f66..1a0944fb96 100644 --- a/README.md +++ b/README.md @@ -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 Prowler SaaS. +## Prowler App + +Prowler App is a web application that allows you to run Prowler in your cloud provider accounts and visualize the results in a user-friendly interface. + +![Prowler App](docs/img/overview.png) + +>More details at [Prowler App Documentation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-app-installation) + ## Prowler CLI ```console @@ -63,42 +71,121 @@ It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, Fe | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | |---|---|---|---|---| -| AWS | 553 | 77 -> `prowler aws --list-services` | 30 -> `prowler aws --list-compliance` | 9 -> `prowler aws --list-categories` | +| AWS | 561 | 81 -> `prowler aws --list-services` | 30 -> `prowler aws --list-compliance` | 9 -> `prowler aws --list-categories` | | GCP | 77 | 13 -> `prowler gcp --list-services` | 3 -> `prowler gcp --list-compliance` | 2 -> `prowler gcp --list-categories`| -| Azure | 138 | 17 -> `prowler azure --list-services` | 4 -> `prowler azure --list-compliance` | 2 -> `prowler azure --list-categories` | +| Azure | 139 | 18 -> `prowler azure --list-services` | 4 -> `prowler azure --list-compliance` | 2 -> `prowler azure --list-categories` | | Kubernetes | 83 | 7 -> `prowler kubernetes --list-services` | 1 -> `prowler kubernetes --list-compliance` | 7 -> `prowler kubernetes --list-categories` | # 💻 Installation -## Pip package -Prowler is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/), thus can be installed using pip with Python >= 3.9, < 3.13: +## Prowler App + +Prowler App can be installed in different ways, depending on your environment: + +> See how to use Prowler App in the [Prowler App Usage Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/). + +### Docker Compose + +**Requirements** + +* `Docker Compose` installed: https://docs.docker.com/compose/install/. + +**Commands** + +``` console +curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/docker-compose.yml +curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/.env +docker compose up -d +``` + +> Enjoy Prowler App at http://localhost:3000 by signing up with your email and password. + +### From GitHub + +**Requirements** + +* `git` installed. +* `poetry` installed: [poetry installation](https://python-poetry.org/docs/#installation). +* `npm` installed: [npm installation](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). +* `Docker Compose` installed: https://docs.docker.com/compose/install/. + +**Commands to run the API** + +``` console +git clone https://github.com/prowler-cloud/prowler +cd prowler/api +poetry install +poetry shell +set -a +source .env +docker compose up postgres valkey -d +cd src/backend +python manage.py migrate --database admin +gunicorn -c config/guniconf.py config.wsgi:application +``` + +> Now, you can access the API documentation at http://localhost:8080/api/v1/docs. + +**Commands to run the API Worker** + +``` console +git clone https://github.com/prowler-cloud/prowler +cd prowler/api +poetry install +poetry shell +set -a +source .env +cd src/backend +python -m celery -A config.celery worker -l info -E +``` + +**Commands to run the UI** + +``` console +git clone https://github.com/prowler-cloud/prowler +cd prowler/ui +npm install +npm run build +npm start +``` + +> Enjoy Prowler App at http://localhost:3000 by signing up with your email and password. + +## Prowler CLI +### Pip package +Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/), thus can be installed using pip with Python >= 3.9, < 3.13: ```console pip install prowler prowler -v ``` ->More details at [https://docs.prowler.com](https://docs.prowler.com/projects/prowler-open-source/en/latest/) +>More details at [https://docs.prowler.com](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-cli-installation) -## Containers +### Containers -The available versions of Prowler are the following: +The available versions of Prowler CLI are the following: - `latest`: in sync with `master` branch (bear in mind that it is not a stable version) +- `v4-latest`: in sync with `v4` branch (bear in mind that it is not a stable version) - `v3-latest`: in sync with `v3` branch (bear in mind that it is not a stable version) - `` (release): you can find the releases [here](https://github.com/prowler-cloud/prowler/releases), those are stable releases. - `stable`: this tag always point to the latest release. +- `v4-stable`: this tag always point to the latest release for v4. - `v3-stable`: this tag always point to the latest release for v3. The container images are available here: +- Prowler CLI: + - [DockerHub](https://hub.docker.com/r/toniblyx/prowler/tags) + - [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler) +- Prowler App: + - [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags) + - [DockerHub - Prowler API](https://hub.docker.com/r/prowlercloud/prowler-api/tags) -- [DockerHub](https://hub.docker.com/r/toniblyx/prowler/tags) -- [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler) - -## From GitHub +### From GitHub Python >= 3.9, < 3.13 is required with pip and poetry: -``` +``` console git clone https://github.com/prowler-cloud/prowler cd prowler poetry shell @@ -108,6 +195,16 @@ python prowler.py -v > If you want to clone Prowler from Windows, use `git config core.longpaths true` to allow long file paths. # 📐✏️ High level architecture +## Prowler App +The **Prowler App** consists of three main components: + +- **Prowler UI**: A user-friendly web interface for running Prowler and viewing results, powered by Next.js. +- **Prowler API**: The backend API that executes Prowler scans and stores the results, built with Django REST Framework. +- **Prowler SDK**: A Python SDK that integrates with the Prowler CLI for advanced functionality. + +![Prowler App Architecture](docs/img/prowler-app-architecture.png) + +## Prowler CLI You can run Prowler from your workstation, a Kubernetes Job, a Google Compute Engine, an Azure VM, an EC2 instance, Fargate or any other container, CloudShell and many more. ![Architecture](docs/img/architecture.png) diff --git a/api/.env.example b/api/.env.example index 3506531aac..f0e72562fe 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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' diff --git a/api/poetry.lock b/api/poetry.lock index ef5e2bfe26..c03e9e4b18 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -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] diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index d90c2c2340..d9255d193d 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -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 diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 8f3ee1125e..0bf6121996 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -4,48 +4,49 @@ 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, + Finding, Membership, Provider, ProviderGroup, Resource, ResourceTag, Scan, - Task, - StateChoices, - Finding, + ScanSummary, SeverityChoices, + StateChoices, StatusChoices, ProviderSecret, Invitation, Role, 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 @@ -58,6 +59,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 @@ -157,7 +165,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 @@ -165,6 +178,7 @@ class ScanFilter(ProviderRelationshipFilterSet): "provider": ["exact", "in"], "name": ["exact", "icontains"], "started_at": ["gte", "lte"], + "next_scan_at": ["gte", "lte"], "trigger": ["exact"], } @@ -497,3 +511,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"], + } diff --git a/api/src/backend/api/migrations/0001_initial.py b/api/src/backend/api/migrations/0001_initial.py index 597cad1101..6b39fe3fe2 100644 --- a/api/src/backend/api/migrations/0001_initial.py +++ b/api/src/backend/api/migrations/0001_initial.py @@ -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( @@ -1575,6 +1579,67 @@ class Migration(migrations.Migration): statements=["SELECT", "INSERT", "UPDATE", "DELETE"], ), ), + migrations.CreateModel( + name="InvitationRoleRelationship", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "invitation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.invitation" + ), + ), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.role" + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "role_invitation_relationship", + }, + ), + migrations.AddConstraint( + model_name="invitationrolerelationship", + constraint=models.UniqueConstraint( + fields=("role_id", "invitation_id"), + name="unique_role_invitation_relationship", + ), + ), + migrations.AddConstraint( + model_name="invitationrolerelationship", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_invitationrolerelationship", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + migrations.AddField( + model_name="role", + name="invitations", + field=models.ManyToManyField( + related_name="roles", + through="api.InvitationRoleRelationship", + to="api.invitation", + ), + ), migrations.CreateModel( name="ComplianceOverview", fields=[ @@ -1655,7 +1720,7 @@ class Migration(migrations.Migration): ), ), migrations.CreateModel( - name="InvitationRoleRelationship", + name="ScanSummary", fields=[ ( "id", @@ -1667,17 +1732,41 @@ class Migration(migrations.Migration): ), ), ("inserted_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), + ("check_id", models.CharField(max_length=100)), + ("service", models.TextField()), ( - "invitation", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="api.invitation" + "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)), ( - "role", + "scan", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="api.role" + on_delete=django.db.models.deletion.CASCADE, + related_name="aggregations", + related_query_name="aggregation", + to="api.scan", ), ), ( @@ -1688,31 +1777,23 @@ class Migration(migrations.Migration): ), ], options={ - "db_table": "role_invitation_relationship", + "db_table": "scan_summaries", + "abstract": False, }, ), migrations.AddConstraint( - model_name="invitationrolerelationship", - constraint=models.UniqueConstraint( - fields=("role_id", "invitation_id"), - name="unique_role_invitation_relationship", - ), - ), - migrations.AddConstraint( - model_name="invitationrolerelationship", + model_name="scansummary", constraint=api.rls.RowLevelSecurityConstraint( "tenant_id", - name="rls_on_invitationrolerelationship", + name="rls_on_scansummary", statements=["SELECT", "INSERT", "UPDATE", "DELETE"], ), ), - migrations.AddField( - model_name="role", - name="invitations", - field=models.ManyToManyField( - related_name="roles", - through="api.InvitationRoleRelationship", - to="api.invitation", + migrations.AddConstraint( + model_name="scansummary", + constraint=models.UniqueConstraint( + fields=("tenant", "scan", "check_id", "service", "severity", "region"), + name="unique_scan_summary", ), ), ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 5ae1aea2d7..8f4f2584d1 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -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") @@ -925,6 +967,9 @@ class InvitationRoleRelationship(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) @@ -971,3 +1016,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" diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index bd8970423e..6895641dfc 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -751,6 +751,429 @@ paths: schema: $ref: '#/components/schemas/FindingResponse' description: '' + /api/v1/findings/findings_services_regions: + get: + operationId: findings_findings_services_regions_retrieve + description: Fetch services and regions affected in findings. + summary: Retrieve the services and regions that are impacted by findings + parameters: + - in: query + name: fields[finding-dynamic-filters] + schema: + type: array + items: + type: string + enum: + - services + - regions + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[check_id] + schema: + type: string + - in: query + name: filter[check_id__icontains] + schema: + type: string + - in: query + name: filter[check_id__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[delta] + schema: + type: string + nullable: true + enum: + - changed + - new + description: |- + * `new` - New + * `changed` - Changed + - in: query + name: filter[delta__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[id] + schema: + type: string + format: uuid + - in: query + name: filter[id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[impact] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[impact__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_name] + schema: + type: string + - in: query + name: filter[resource_name__icontains] + schema: + type: string + - in: query + name: filter[resource_name__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_type] + schema: + type: string + - in: query + name: filter[resource_type__icontains] + schema: + type: string + - in: query + name: filter[resource_type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_uid] + schema: + type: string + - in: query + name: filter[resource_uid__icontains] + schema: + type: string + - in: query + name: filter[resource_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resources] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[severity] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[severity__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - MANUAL + - MUTED + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + * `MUTED` - Muted + - in: query + name: filter[status__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[updated_at] + schema: + type: string + format: date + - in: query + name: filter[updated_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[updated_at__lte] + schema: + type: string + format: date-time + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - status + - -status + - severity + - -severity + - check_id + - -check_id + - inserted_at + - -inserted_at + - updated_at + - -updated_at + explode: false + tags: + - Finding + security: + - jwtAuth: [] + responses: + '201': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/OpenApiResponseResponse' + description: '' /api/v1/invitations/accept: post: operationId: invitations_accept_create @@ -780,16 +1203,332 @@ paths: schema: $ref: '#/components/schemas/OpenApiResponseResponse' description: '' + /api/v1/overviews/findings: + get: + operationId: overviews_findings_retrieve + 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. + summary: Get aggregated findings data + parameters: + - in: query + name: fields[findings-overview] + schema: + type: array + items: + type: string + enum: + - id + - new + - changed + - unchanged + - fail_new + - fail_changed + - pass_new + - pass_changed + - muted_new + - muted_changed + - total + - fail + - muted + - pass + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[muted_findings] + schema: + type: boolean + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - new + - -new + - changed + - -changed + - unchanged + - -unchanged + - fail_new + - -fail_new + - fail_changed + - -fail_changed + - pass_new + - -pass_new + - pass_changed + - -pass_changed + - muted_new + - -muted_new + - muted_changed + - -muted_changed + - total + - -total + - fail + - -fail + - muted + - -muted + - pass + - -pass + explode: false + tags: + - Overview + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/OverviewFindingResponse' + description: '' + /api/v1/overviews/findings_severity: + get: + operationId: overviews_findings_severity_retrieve + 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. + summary: Get findings data by severity + parameters: + - in: query + name: fields[findings-severity-overview] + schema: + type: array + items: + type: string + enum: + - id + - critical + - high + - medium + - low + - informational + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[muted_findings] + schema: + type: boolean + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - critical + - -critical + - high + - -high + - medium + - -medium + - low + - -low + - informational + - -informational + explode: false + tags: + - Overview + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/OverviewSeverityResponse' + description: '' /api/v1/overviews/providers: get: operationId: overviews_providers_retrieve - 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: List aggregated overview data for providers + 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. + summary: Get aggregated provider data parameters: - in: query - name: fields[provider-overviews] + name: fields[providers-overview] schema: type: array items: @@ -1244,6 +1983,17 @@ paths: schema: type: string format: date-time + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider_groups + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false - name: page[number] required: false in: query @@ -1349,6 +2099,17 @@ paths: format: uuid description: A UUID string identifying this provider. required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider_groups + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false tags: - Provider security: @@ -2379,9 +3140,11 @@ paths: - duration - provider - task + - inserted_at - started_at - completed_at - scheduled_at + - next_scan_at - url description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. @@ -2404,6 +3167,21 @@ paths: name: filter[name__icontains] schema: type: string + - in: query + name: filter[next_scan_at] + schema: + type: string + format: date + - in: query + name: filter[next_scan_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[next_scan_at__lte] + schema: + type: string + format: date-time - in: query name: filter[provider] schema: @@ -2508,6 +3286,48 @@ paths: schema: type: string format: date-time + - in: query + name: filter[state] + schema: + type: string + enum: + - available + - cancelled + - completed + - executing + - failed + - scheduled + description: |- + * `available` - Available + * `scheduled` - Scheduled + * `executing` - Executing + * `completed` - Completed + * `failed` - Failed + * `cancelled` - Cancelled + - in: query + name: filter[state__in] + schema: + type: array + items: + type: string + enum: + - available + - cancelled + - completed + - executing + - failed + - scheduled + description: |- + Multiple values may be separated by commas. + + * `available` - Available + * `scheduled` - Scheduled + * `executing` - Executing + * `completed` - Completed + * `failed` - Failed + * `cancelled` - Cancelled + explode: false + style: form - in: query name: filter[trigger] schema: @@ -2616,9 +3436,11 @@ paths: - duration - provider - task + - inserted_at - started_at - completed_at - scheduled_at + - next_scan_at - url description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. @@ -2677,6 +3499,35 @@ paths: schema: $ref: '#/components/schemas/ScanUpdateResponse' description: '' + /api/v1/schedules/daily: + post: + operationId: schedules_daily_create + description: Schedules a daily scan for the specified provider. This endpoint + creates a periodic task that will execute a scan every 24 hours. + summary: Create a daily schedule scan for a given provider + tags: + - Schedule + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ScheduleDailyCreateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ScheduleDailyCreateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/ScheduleDailyCreateRequest' + required: true + security: + - jwtAuth: [] + responses: + '202': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/OpenApiResponseResponse' + description: '' /api/v1/tasks: get: operationId: tasks_list @@ -4881,6 +5732,7 @@ components: type: object required: - type + - id additionalProperties: false properties: type: @@ -4889,6 +5741,9 @@ components: description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + id: + type: string + format: uuid attributes: type: object properties: @@ -4977,6 +5832,77 @@ components: $ref: '#/components/schemas/Membership' required: - data + OverviewFinding: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/OverviewFindingTypeEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + id: {} + attributes: + type: object + properties: + id: + type: string + default: n/a + new: + type: integer + changed: + type: integer + unchanged: + type: integer + fail_new: + type: integer + fail_changed: + type: integer + pass_new: + type: integer + pass_changed: + type: integer + muted_new: + type: integer + muted_changed: + type: integer + total: + type: integer + fail: + type: integer + muted: + type: integer + pass: + type: integer + required: + - new + - changed + - unchanged + - fail_new + - fail_changed + - pass_new + - pass_changed + - muted_new + - muted_changed + - total + - fail + - muted + - pass + OverviewFindingResponse: + type: object + properties: + data: + $ref: '#/components/schemas/OverviewFinding' + required: + - data + OverviewFindingTypeEnum: + type: string + enum: + - findings-overview OverviewProvider: type: object required: @@ -5026,7 +5952,54 @@ components: OverviewProviderTypeEnum: type: string enum: - - provider-overviews + - providers-overview + OverviewSeverity: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/OverviewSeverityTypeEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + id: {} + attributes: + type: object + properties: + id: + type: string + default: n/a + critical: + type: integer + high: + type: integer + medium: + type: integer + low: + type: integer + informational: + type: integer + required: + - critical + - high + - medium + - low + - informational + OverviewSeverityResponse: + type: object + properties: + data: + $ref: '#/components/schemas/OverviewSeverity' + required: + - data + OverviewSeverityTypeEnum: + type: string + enum: + - findings-severity-overview PaginatedComplianceOverviewList: type: object required: @@ -5839,7 +6812,7 @@ components: type: type: string enum: - - provider-group + - provider-groups title: Resource Type Name description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common @@ -5849,8 +6822,8 @@ components: - type required: - data - description: A related resource object from type provider-group - title: provider-group + description: A related resource object from type provider-groups + title: provider-groups readOnly: true required: - secret @@ -7513,6 +8486,10 @@ components: maximum: 2147483647 minimum: -2147483648 nullable: true + inserted_at: + type: string + format: date-time + readOnly: true started_at: type: string format: date-time @@ -7525,6 +8502,10 @@ components: type: string format: date-time nullable: true + next_scan_at: + type: string + format: date-time + nullable: true relationships: type: object properties: @@ -7672,6 +8653,32 @@ components: $ref: '#/components/schemas/ScanUpdate' required: - data + ScheduleDailyCreateRequest: + type: object + properties: + data: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - daily-schedules + attributes: + type: object + properties: + provider_id: + type: string + format: uuid + required: + - provider_id + required: + - data SerializerMetaclassResponse: type: object properties: diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index c4804b801e..7887750fbf 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -1,32 +1,28 @@ 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, Membership, Provider, ProviderGroup, ProviderGroupMembership, Role, RoleProviderGroupRelationship, - Scan, - ProviderSecret, Invitation, UserRoleRelationship, + ProviderSecret, + Scan, + StateChoices, + User, ) from api.rls import Tenant -from conftest import ( - API_JSON_CONTENT_TYPE, - TEST_PASSWORD, - TEST_USER, -) TODAY = str(datetime.today().date()) @@ -799,11 +795,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( @@ -1805,7 +1840,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, @@ -1949,6 +1984,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), ] ), ) @@ -2466,6 +2505,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: @@ -3036,9 +3141,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) + ) }, ) @@ -3717,3 +3822,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 diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 4ac091002a..6aa419857d 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -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,14 +14,10 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework_simplejwt.tokens import RefreshToken from api.models import ( - StateChoices, - User, Membership, Provider, ProviderGroup, ProviderGroupMembership, - Scan, - Task, Resource, ResourceTag, Finding, @@ -32,10 +28,13 @@ from api.models import ( RoleProviderGroupRelationship, UserRoleRelationship, ComplianceOverview, + Scan, + StateChoices, + Task, + User, ) from api.rls import Tenant - # Tokens @@ -476,6 +475,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. @@ -539,6 +544,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 = [ @@ -624,9 +633,11 @@ class ScanSerializer(RLSSerializer): "duration", "provider", "task", + "inserted_at", "started_at", "completed_at", "scheduled_at", + "next_scan_at", "url", ] @@ -795,6 +806,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 @@ -1517,7 +1536,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"} @@ -1553,3 +1572,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 diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index 920afeabea..b23a3efa80 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -1,20 +1,12 @@ -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 ( CustomTokenObtainView, CustomTokenRefreshView, - SchemaView, - UserViewSet, - TenantViewSet, - TenantMembersViewSet, - MembershipViewSet, - ProviderViewSet, - ScanViewSet, - TaskViewSet, - ResourceViewSet, FindingViewSet, + MembershipViewSet, ProviderGroupViewSet, ProviderSecretViewSet, InvitationViewSet, @@ -24,6 +16,15 @@ from api.v1.views import ( UserRoleRelationshipView, OverviewViewSet, ComplianceOverviewViewSet, + ProviderViewSet, + ResourceViewSet, + ScanViewSet, + ScheduleViewSet, + SchemaView, + TaskViewSet, + TenantMembersViewSet, + TenantViewSet, + UserViewSet, ) router = routers.DefaultRouter(trailing_slash=False) @@ -41,6 +42,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( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 587aac980d..816ad23d9b 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -1,22 +1,23 @@ 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_jsonapi.schemas.openapi import JsonApiAutoSchema 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, @@ -24,46 +25,57 @@ 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 RelationshipView, Response -from rest_framework_simplejwt.exceptions import InvalidToken -from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError from rest_framework.permissions import SAFE_METHODS -from api.base_views import BaseTenantViewset, BaseRLSViewSet, BaseUserViewset +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 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, RoleFilter, - ComplianceOverviewFilter, ) from api.models import ( StatusChoices, User, UserRoleRelationship, + ComplianceOverview, + Finding, + Invitation, Membership, Provider, ProviderGroup, ProviderGroupMembership, - Scan, - Task, - Resource, - Finding, ProviderSecret, - Invitation, Role, RoleProviderGroupRelationship, - ComplianceOverview, + Resource, + Scan, + ScanSummary, + SeverityChoices, + StateChoices, + Task, ) from api.pagination import ComplianceOverviewPagination from api.rbac.permissions import DISABLE_RBAC, HasPermissions, Permissions @@ -77,13 +89,24 @@ from api.v1.serializers import ( UserCreateSerializer, UserUpdateSerializer, UserRoleRelationshipSerializer, + ComplianceOverviewFullSerializer, + ComplianceOverviewSerializer, + FindingDynamicFilterSerializer, + FindingSerializer, + InvitationAcceptSerializer, + InvitationCreateSerializer, + InvitationSerializer, + InvitationUpdateSerializer, MembershipSerializer, + OverviewFindingSerializer, + OverviewProviderSerializer, + OverviewSeveritySerializer, + ProviderCreateSerializer, + ProviderGroupMembershipUpdateSerializer, ProviderGroupSerializer, ProviderGroupUpdateSerializer, - ProviderGroupMembershipUpdateSerializer, RoleProviderGroupRelationshipSerializer, ProviderSerializer, - ProviderCreateSerializer, ProviderUpdateSerializer, TenantSerializer, TaskSerializer, @@ -91,28 +114,16 @@ from api.v1.serializers import ( ScanCreateSerializer, ScanUpdateSerializer, ResourceSerializer, - FindingSerializer, ProviderSecretSerializer, ProviderSecretUpdateSerializer, ProviderSecretCreateSerializer, - InvitationSerializer, - InvitationCreateSerializer, - InvitationUpdateSerializer, - InvitationAcceptSerializer, RoleSerializer, RoleCreateSerializer, RoleUpdateSerializer, - ComplianceOverviewSerializer, - ComplianceOverviewFullSerializer, - OverviewProviderSerializer, -) -from tasks.beat import schedule_provider_scan -from tasks.tasks import ( - check_provider_connection_task, - delete_provider_task, - perform_scan_task, + ScheduleDailyCreateSerializer, ) + CACHE_DECORATOR = cache_control( max_age=django_settings.CACHE_MAX_AGE, stale_while_revalidate=django_settings.CACHE_STALE_WHILE_REVALIDATE, @@ -920,7 +931,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 @@ -937,14 +951,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( @@ -1059,12 +1065,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 @@ -1259,6 +1271,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") @@ -1293,6 +1312,12 @@ class FindingViewSet(BaseRLSViewSet): self.required_permissions = ResourceViewSet.required_permissions super().initial(request, *args, **kwargs) + def get_serializer_class(self): + if self.action == "findings_services_regions": + return FindingDynamicFilterSerializer + + return super().get_serializer_class() + def get_queryset(self): user = self.request.user user_roles = user.roles.all() @@ -1343,6 +1368,27 @@ class FindingViewSet(BaseRLSViewSet): return None return datetime_to_uuid7(inserted_at) + @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( @@ -1770,26 +1816,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") @@ -1841,7 +1928,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( { @@ -1857,3 +1944,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} + ) + }, + ) diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py index d94fb1aa1e..2206a94797 100644 --- a/api/src/backend/config/celery.py +++ b/api/src/backend/config/celery.py @@ -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, diff --git a/api/src/backend/config/settings/celery.py b/api/src/backend/config/settings/celery.py index c7e55140ea..a2aba00007 100644 --- a/api/src/backend/config/settings/celery.py +++ b/api/src/backend/config/settings/celery.py @@ -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) diff --git a/api/src/backend/tasks/beat.py b/api/src/backend/tasks/beat.py index b53fb700c9..81dc04abba 100644 --- a/api/src/backend/tasks/beat.py +++ b/api/src/backend/tasks/beat.py @@ -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), + }, ) diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py index b203cf113e..126b8f3693 100644 --- a/api/src/backend/tasks/jobs/deletion.py +++ b/api/src/backend/tasks/jobs/deletion.py @@ -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 diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 2b2fea8f17..ffc75d1f37 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -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) diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 4fc933127e..54c98c51e1 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -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) diff --git a/api/src/backend/tasks/tests/test_beat.py b/api/src/backend/tasks/tests/test_beat.py new file mode 100644 index 0000000000..78b5acb039 --- /dev/null +++ b/api/src/backend/tasks/tests/test_beat.py @@ -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 + ) diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py index 630d1d1fa1..27bfd5a781 100644 --- a/api/src/backend/tasks/tests/test_deletion.py +++ b/api/src/backend/tasks/tests/test_deletion.py @@ -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) diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 798ebcb354..da79f78555 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -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 diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000000..2c39e2d9d2 --- /dev/null +++ b/docker-compose-dev.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..281c79ddf8 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/docs/img/add-account.png b/docs/img/add-account.png new file mode 100644 index 0000000000..35333883b5 Binary files /dev/null and b/docs/img/add-account.png differ diff --git a/docs/img/add-provider.png b/docs/img/add-provider.png new file mode 100644 index 0000000000..4e986e3f1a Binary files /dev/null and b/docs/img/add-provider.png differ diff --git a/docs/img/aws-credentials.png b/docs/img/aws-credentials.png new file mode 100644 index 0000000000..71098075db Binary files /dev/null and b/docs/img/aws-credentials.png differ diff --git a/docs/img/aws-role.png b/docs/img/aws-role.png new file mode 100644 index 0000000000..bd63511fb6 Binary files /dev/null and b/docs/img/aws-role.png differ diff --git a/docs/img/azure-credentials.png b/docs/img/azure-credentials.png new file mode 100644 index 0000000000..2f5448d0b9 Binary files /dev/null and b/docs/img/azure-credentials.png differ diff --git a/docs/img/connect-aws-credentials.png b/docs/img/connect-aws-credentials.png new file mode 100644 index 0000000000..7ea1f193c3 Binary files /dev/null and b/docs/img/connect-aws-credentials.png differ diff --git a/docs/img/connect-aws-role.png b/docs/img/connect-aws-role.png new file mode 100644 index 0000000000..78c8f6f390 Binary files /dev/null and b/docs/img/connect-aws-role.png differ diff --git a/docs/img/findings.png b/docs/img/findings.png new file mode 100644 index 0000000000..e2ea9c56ce Binary files /dev/null and b/docs/img/findings.png differ diff --git a/docs/img/gcp-credentials.png b/docs/img/gcp-credentials.png new file mode 100644 index 0000000000..4f6dae3b1e Binary files /dev/null and b/docs/img/gcp-credentials.png differ diff --git a/docs/img/issues.png b/docs/img/issues.png new file mode 100644 index 0000000000..009bc0d447 Binary files /dev/null and b/docs/img/issues.png differ diff --git a/docs/img/kubernetes-credentials.png b/docs/img/kubernetes-credentials.png new file mode 100644 index 0000000000..b461ed218a Binary files /dev/null and b/docs/img/kubernetes-credentials.png differ diff --git a/docs/img/log-in.png b/docs/img/log-in.png new file mode 100644 index 0000000000..9e6d410abe Binary files /dev/null and b/docs/img/log-in.png differ diff --git a/docs/img/overview.png b/docs/img/overview.png new file mode 100644 index 0000000000..a4975bb234 Binary files /dev/null and b/docs/img/overview.png differ diff --git a/docs/img/provider-added.png b/docs/img/provider-added.png new file mode 100644 index 0000000000..cfb94d2cab Binary files /dev/null and b/docs/img/provider-added.png differ diff --git a/docs/img/prowler-app-architecture.png b/docs/img/prowler-app-architecture.png new file mode 100644 index 0000000000..889bc0da88 Binary files /dev/null and b/docs/img/prowler-app-architecture.png differ diff --git a/docs/img/scan-progress.png b/docs/img/scan-progress.png new file mode 100644 index 0000000000..3378775dcd Binary files /dev/null and b/docs/img/scan-progress.png differ diff --git a/docs/img/select-provider.png b/docs/img/select-provider.png new file mode 100644 index 0000000000..f738d04fb7 Binary files /dev/null and b/docs/img/select-provider.png differ diff --git a/docs/img/services.png b/docs/img/services.png new file mode 100644 index 0000000000..e82a441576 Binary files /dev/null and b/docs/img/services.png differ diff --git a/docs/img/sign-up-button.png b/docs/img/sign-up-button.png new file mode 100644 index 0000000000..b7006e72e5 Binary files /dev/null and b/docs/img/sign-up-button.png differ diff --git a/docs/img/sign-up.png b/docs/img/sign-up.png new file mode 100644 index 0000000000..56e8901b24 Binary files /dev/null and b/docs/img/sign-up.png differ diff --git a/docs/img/test-connection-button.png b/docs/img/test-connection-button.png new file mode 100644 index 0000000000..261cb035c7 Binary files /dev/null and b/docs/img/test-connection-button.png differ diff --git a/docs/index.md b/docs/index.md index 2b06f269bf..6f21f75023 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 Prowler SaaS. -## Prowler CLI +## Prowler App + +![Prowler App](img/overview.png) + +Prowler App is a web application that allows you to run Prowler in a simple way. It provides a user-friendly interface to configure and run scans, view results, and manage your security findings. + +See how to install the Prowler App in the [Quick Start](#prowler-app-installation) section. + +## Prowler CLI ```console prowler @@ -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) - `` (release): you can find the releases [here](https://github.com/prowler-cloud/prowler/releases), those are stable releases. - `stable`: this tag always point to the latest release. +- `v4-stable`: this tag always point to the latest release for v4. - `v3-stable`: this tag always point to the latest release for v3. The container images are available here: - -- [DockerHub](https://hub.docker.com/r/toniblyx/prowler/tags) -- [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler) +- Prowler CLI: + - [DockerHub](https://hub.docker.com/r/toniblyx/prowler/tags) + - [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler) +- Prowler App: + - [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags) + - [DockerHub - Prowler API](https://hub.docker.com/r/prowlercloud/prowler-api/tags) ## High level architecture @@ -214,6 +315,22 @@ You can run Prowler from your workstation, a Kubernetes Job, a Google Compute En ![Architecture](img/architecture.png) +### Prowler App + +The **Prowler App** consists of three main components: + +- **Prowler UI**: A user-friendly web interface for running Prowler and viewing results, powered by Next.js. +- **Prowler API**: The backend API that executes Prowler scans and stores the results, built with Django REST Framework. +- **Prowler SDK**: A Python SDK that integrates with the Prowler CLI for advanced functionality. + +The app leverages the following supporting infrastructure: + +- **PostgreSQL**: Used for persistent storage of scan results. +- **Celery Workers**: Facilitate asynchronous execution of Prowler scans. +- **Valkey**: An in-memory database serving as a message broker for the Celery workers. + +![Prowler App Architecture](img/prowler-app-architecture.png) + ## Deprecations from v3 ### General @@ -231,6 +348,40 @@ We have deprecated some of our outputs formats: - To send only FAILS to AWS Security Hub, now use either `--send-sh-only-fails` or `--security-hub --status FAIL`. ## Basic Usage +### Prowler App + +#### **Access the App** +Go to [http://localhost:3000](http://localhost:3000) after installing the app (see [Quick Start](#prowler-app-installation)). Sign up with your email and password. + +Sign Up Button +Sign Up + +#### **Log In** +Log in with your email and password to start using the Prowler App. + +Log In + +#### **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. Overview +- **Compliance**: Insights into compliance status. Compliance + +> 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 ... ``` -### 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 ... 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. diff --git a/docs/tutorials/azure/create-prowler-service-principal.md b/docs/tutorials/azure/create-prowler-service-principal.md index 7cb8c30a6b..84d620d0d2 100644 --- a/docs/tutorials/azure/create-prowler-service-principal.md +++ b/docs/tutorials/azure/create-prowler-service-principal.md @@ -11,7 +11,7 @@ To allow Prowler assume an identity to start the scan with the required privileg 7. Fill the "Description" and "Expires" fields and click on "Add" 8. Copy the value of the secret, it is going to be used as `AZURE_CLIENT_SECRET` environment variable. -![Register an Application page](../../img/create-sp.gif) +![Register an Application page](../img/create-sp.gif) ## Assigning the proper permissions diff --git a/docs/tutorials/configuration_file.md b/docs/tutorials/configuration_file.md index d4ef971d41..866c7f8dff 100644 --- a/docs/tutorials/configuration_file.md +++ b/docs/tutorials/configuration_file.md @@ -41,7 +41,7 @@ The following list includes all the AWS checks with configurable variables that | `ec2_launch_template_no_secrets` | `secrets_ignore_patterns` | List of Strings | | `ec2_securitygroup_allow_ingress_from_internet_to_any_port` | `ec2_allowed_instance_owners` | List of Strings | | `ec2_securitygroup_allow_ingress_from_internet_to_any_port` | `ec2_allowed_interface_types` | List of Strings | -| `ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports`| `ec2_sg_high_risk_ports` | List of Integer | +| `ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports`| `ec2_high_risk_ports` | List of Integer | | `ec2_securitygroup_with_many_ingress_egress_rules` | `max_security_group_rules` | Integer | | `ecs_task_definitions_no_environment_secrets` | `secrets_ignore_patterns` | List of Strings | | `ecr_repositories_scan_vulnerabilities_in_latest_image` | `ecr_repository_vulnerability_minimum_severity` | String | @@ -144,7 +144,7 @@ aws: "amazon-elb" ] # aws.ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports - ec2_sg_high_risk_ports: + ec2_high_risk_ports: [ 25, 110, diff --git a/docs/tutorials/prowler-app.md b/docs/tutorials/prowler-app.md new file mode 100644 index 0000000000..9133c8fe7b --- /dev/null +++ b/docs/tutorials/prowler-app.md @@ -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: + +Sign Up Button +Sign Up + +--- + +## **Step 2: Log In** +Once you’ve signed up, log in with your email and password to start using the Prowler App. + +Log In + +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: + +Add Provider + +--- + +## **Step 4: Configure the Provider** +Choose the provider you want to scan from the following options: + +Select a Provider + +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`. + + AWS Credentials + +2. Enter your `Access Key ID`, `Secret Access Key` and optionally a `Session Token`: + + AWS Credentials + +#### **Step 4.1.2: IAM Role** +1. Select `Connect assuming IAM Role`. + + AWS Role + +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`: + + AWS Role + +--- + +### **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. + +Azure Credentials + +--- +### **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. + +GCP Credentials + +--- +### **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`. + +Kubernetes Credentials + +--- + +## **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: + +Test Connection + + +## **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: + +Start Now + +???+ 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: + +Scan Progress + +--- + +## **Step 8: Analyze the Findings** +While the scan is running, start exploring the findings in these sections: + +- **Overview**: High-level summary of the scans. Overview +- **Compliance**: Insights into compliance status. Compliance +- **Issues**: Types of issues detected. + +Issues + +- **Browse All Findings**: Detailed list of findings detected, where you can filter by severity, service, and more. Findings diff --git a/mkdocs.yml b/mkdocs.yml index 170a9c3d9d..e311146609 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index 74c6ba8e6d..aa8e01afa7 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -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", diff --git a/poetry.lock b/poetry.lock index 456759fa56..3261d5eaa4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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.69" +version = "1.35.71" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.35.69-py3-none-any.whl", hash = "sha256:cad8d9305f873404eee4b197d84e60a40975d43cbe1ab63abe893420ddfe6e3c"}, - {file = "botocore-1.35.69.tar.gz", hash = "sha256:f9f23dd76fb247d9b0e8d411d2995e6f847fc451c026f1e58e300f815b0b36eb"}, + {file = "botocore-1.35.71-py3-none-any.whl", hash = "sha256:fc46e7ab1df3cef66dfba1633f4da77c75e07365b36f03bd64a3793634be8fc1"}, + {file = "botocore-1.35.71.tar.gz", hash = "sha256:f9fa058e0393660c3fe53c1e044751beb64b586def0bd2212448a7c328b0cbba"}, ] [package.dependencies] @@ -1099,73 +1099,73 @@ files = [ [[package]] name = "coverage" -version = "7.6.7" +version = "7.6.8" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" files = [ - {file = "coverage-7.6.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:108bb458827765d538abcbf8288599fee07d2743357bdd9b9dad456c287e121e"}, - {file = "coverage-7.6.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c973b2fe4dc445cb865ab369df7521df9c27bf40715c837a113edaa2aa9faf45"}, - {file = "coverage-7.6.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c6b24007c4bcd0b19fac25763a7cac5035c735ae017e9a349b927cfc88f31c1"}, - {file = "coverage-7.6.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:acbb8af78f8f91b3b51f58f288c0994ba63c646bc1a8a22ad072e4e7e0a49f1c"}, - {file = "coverage-7.6.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad32a981bcdedb8d2ace03b05e4fd8dace8901eec64a532b00b15217d3677dd2"}, - {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:34d23e28ccb26236718a3a78ba72744212aa383141961dd6825f6595005c8b06"}, - {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e25bacb53a8c7325e34d45dddd2f2fbae0dbc230d0e2642e264a64e17322a777"}, - {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af05bbba896c4472a29408455fe31b3797b4d8648ed0a2ccac03e074a77e2314"}, - {file = "coverage-7.6.7-cp310-cp310-win32.whl", hash = "sha256:796c9b107d11d2d69e1849b2dfe41730134b526a49d3acb98ca02f4985eeff7a"}, - {file = "coverage-7.6.7-cp310-cp310-win_amd64.whl", hash = "sha256:987a8e3da7da4eed10a20491cf790589a8e5e07656b6dc22d3814c4d88faf163"}, - {file = "coverage-7.6.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7e61b0e77ff4dddebb35a0e8bb5a68bf0f8b872407d8d9f0c726b65dfabe2469"}, - {file = "coverage-7.6.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a5407a75ca4abc20d6252efeb238377a71ce7bda849c26c7a9bece8680a5d99"}, - {file = "coverage-7.6.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df002e59f2d29e889c37abd0b9ee0d0e6e38c24f5f55d71ff0e09e3412a340ec"}, - {file = "coverage-7.6.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:673184b3156cba06154825f25af33baa2671ddae6343f23175764e65a8c4c30b"}, - {file = "coverage-7.6.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69ad502f1a2243f739f5bd60565d14a278be58be4c137d90799f2c263e7049a"}, - {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60dcf7605c50ea72a14490d0756daffef77a5be15ed1b9fea468b1c7bda1bc3b"}, - {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9c2eb378bebb2c8f65befcb5147877fc1c9fbc640fc0aad3add759b5df79d55d"}, - {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c0317288f032221d35fa4cbc35d9f4923ff0dfd176c79c9b356e8ef8ef2dff4"}, - {file = "coverage-7.6.7-cp311-cp311-win32.whl", hash = "sha256:951aade8297358f3618a6e0660dc74f6b52233c42089d28525749fc8267dccd2"}, - {file = "coverage-7.6.7-cp311-cp311-win_amd64.whl", hash = "sha256:5e444b8e88339a2a67ce07d41faabb1d60d1004820cee5a2c2b54e2d8e429a0f"}, - {file = "coverage-7.6.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f07ff574986bc3edb80e2c36391678a271d555f91fd1d332a1e0f4b5ea4b6ea9"}, - {file = "coverage-7.6.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:49ed5ee4109258973630c1f9d099c7e72c5c36605029f3a91fe9982c6076c82b"}, - {file = "coverage-7.6.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3e8796434a8106b3ac025fd15417315d7a58ee3e600ad4dbcfddc3f4b14342c"}, - {file = "coverage-7.6.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3b925300484a3294d1c70f6b2b810d6526f2929de954e5b6be2bf8caa1f12c1"}, - {file = "coverage-7.6.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c42ec2c522e3ddd683dec5cdce8e62817afb648caedad9da725001fa530d354"}, - {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0266b62cbea568bd5e93a4da364d05de422110cbed5056d69339bd5af5685433"}, - {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e5f2a0f161d126ccc7038f1f3029184dbdf8f018230af17ef6fd6a707a5b881f"}, - {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c132b5a22821f9b143f87446805e13580b67c670a548b96da945a8f6b4f2efbb"}, - {file = "coverage-7.6.7-cp312-cp312-win32.whl", hash = "sha256:7c07de0d2a110f02af30883cd7dddbe704887617d5c27cf373362667445a4c76"}, - {file = "coverage-7.6.7-cp312-cp312-win_amd64.whl", hash = "sha256:fd49c01e5057a451c30c9b892948976f5d38f2cbd04dc556a82743ba8e27ed8c"}, - {file = "coverage-7.6.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46f21663e358beae6b368429ffadf14ed0a329996248a847a4322fb2e35d64d3"}, - {file = "coverage-7.6.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40cca284c7c310d622a1677f105e8507441d1bb7c226f41978ba7c86979609ab"}, - {file = "coverage-7.6.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77256ad2345c29fe59ae861aa11cfc74579c88d4e8dbf121cbe46b8e32aec808"}, - {file = "coverage-7.6.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87ea64b9fa52bf395272e54020537990a28078478167ade6c61da7ac04dc14bc"}, - {file = "coverage-7.6.7-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d608a7808793e3615e54e9267519351c3ae204a6d85764d8337bd95993581a8"}, - {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdd94501d65adc5c24f8a1a0eda110452ba62b3f4aeaba01e021c1ed9cb8f34a"}, - {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82c809a62e953867cf57e0548c2b8464207f5f3a6ff0e1e961683e79b89f2c55"}, - {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb684694e99d0b791a43e9fc0fa58efc15ec357ac48d25b619f207c41f2fd384"}, - {file = "coverage-7.6.7-cp313-cp313-win32.whl", hash = "sha256:963e4a08cbb0af6623e61492c0ec4c0ec5c5cf74db5f6564f98248d27ee57d30"}, - {file = "coverage-7.6.7-cp313-cp313-win_amd64.whl", hash = "sha256:14045b8bfd5909196a90da145a37f9d335a5d988a83db34e80f41e965fb7cb42"}, - {file = "coverage-7.6.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f2c7a045eef561e9544359a0bf5784b44e55cefc7261a20e730baa9220c83413"}, - {file = "coverage-7.6.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dd4e4a49d9c72a38d18d641135d2fb0bdf7b726ca60a103836b3d00a1182acd"}, - {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c95e0fa3d1547cb6f021ab72f5c23402da2358beec0a8e6d19a368bd7b0fb37"}, - {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f63e21ed474edd23f7501f89b53280014436e383a14b9bd77a648366c81dce7b"}, - {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead9b9605c54d15be228687552916c89c9683c215370c4a44f1f217d2adcc34d"}, - {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0573f5cbf39114270842d01872952d301027d2d6e2d84013f30966313cadb529"}, - {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e2c8e3384c12dfa19fa9a52f23eb091a8fad93b5b81a41b14c17c78e23dd1d8b"}, - {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:70a56a2ec1869e6e9fa69ef6b76b1a8a7ef709972b9cc473f9ce9d26b5997ce3"}, - {file = "coverage-7.6.7-cp313-cp313t-win32.whl", hash = "sha256:dbba8210f5067398b2c4d96b4e64d8fb943644d5eb70be0d989067c8ca40c0f8"}, - {file = "coverage-7.6.7-cp313-cp313t-win_amd64.whl", hash = "sha256:dfd14bcae0c94004baba5184d1c935ae0d1231b8409eb6c103a5fd75e8ecdc56"}, - {file = "coverage-7.6.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37a15573f988b67f7348916077c6d8ad43adb75e478d0910957394df397d2874"}, - {file = "coverage-7.6.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b6cce5c76985f81da3769c52203ee94722cd5d5889731cd70d31fee939b74bf0"}, - {file = "coverage-7.6.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ab9763d291a17b527ac6fd11d1a9a9c358280adb320e9c2672a97af346ac2c"}, - {file = "coverage-7.6.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6cf96ceaa275f071f1bea3067f8fd43bec184a25a962c754024c973af871e1b7"}, - {file = "coverage-7.6.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aee9cf6b0134d6f932d219ce253ef0e624f4fa588ee64830fcba193269e4daa3"}, - {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2bc3e45c16564cc72de09e37413262b9f99167803e5e48c6156bccdfb22c8327"}, - {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:623e6965dcf4e28a3debaa6fcf4b99ee06d27218f46d43befe4db1c70841551c"}, - {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:850cfd2d6fc26f8346f422920ac204e1d28814e32e3a58c19c91980fa74d8289"}, - {file = "coverage-7.6.7-cp39-cp39-win32.whl", hash = "sha256:c296263093f099da4f51b3dff1eff5d4959b527d4f2f419e16508c5da9e15e8c"}, - {file = "coverage-7.6.7-cp39-cp39-win_amd64.whl", hash = "sha256:90746521206c88bdb305a4bf3342b1b7316ab80f804d40c536fc7d329301ee13"}, - {file = "coverage-7.6.7-pp39.pp310-none-any.whl", hash = "sha256:0ddcb70b3a3a57581b450571b31cb774f23eb9519c2aaa6176d3a84c9fc57671"}, - {file = "coverage-7.6.7.tar.gz", hash = "sha256:d79d4826e41441c9a118ff045e4bccb9fdbdcb1d02413e7ea6eb5c87b5439d24"}, + {file = "coverage-7.6.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50"}, + {file = "coverage-7.6.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3985b9be361d8fb6b2d1adc9924d01dec575a1d7453a14cccd73225cb79243ee"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:644ec81edec0f4ad17d51c838a7d01e42811054543b76d4ba2c5d6af741ce2a6"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f188a2402f8359cf0c4b1fe89eea40dc13b52e7b4fd4812450da9fcd210181d"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e19122296822deafce89a0c5e8685704c067ae65d45e79718c92df7b3ec3d331"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13618bed0c38acc418896005732e565b317aa9e98d855a0e9f211a7ffc2d6638"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:193e3bffca48ad74b8c764fb4492dd875038a2f9925530cb094db92bb5e47bed"}, + {file = "coverage-7.6.8-cp310-cp310-win32.whl", hash = "sha256:3988665ee376abce49613701336544041f2117de7b7fbfe91b93d8ff8b151c8e"}, + {file = "coverage-7.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:f56f49b2553d7dd85fd86e029515a221e5c1f8cb3d9c38b470bc38bde7b8445a"}, + {file = "coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4"}, + {file = "coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0"}, + {file = "coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801"}, + {file = "coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9"}, + {file = "coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee"}, + {file = "coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443"}, + {file = "coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad"}, + {file = "coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4"}, + {file = "coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb"}, + {file = "coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b"}, + {file = "coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146"}, + {file = "coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28"}, + {file = "coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d"}, + {file = "coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b"}, + {file = "coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71"}, + {file = "coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc"}, + {file = "coverage-7.6.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ac47fa29d8d41059ea3df65bd3ade92f97ee4910ed638e87075b8e8ce69599e"}, + {file = "coverage-7.6.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:24eda3a24a38157eee639ca9afe45eefa8d2420d49468819ac5f88b10de84f4c"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4c81ed2820b9023a9a90717020315e63b17b18c274a332e3b6437d7ff70abe0"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd55f8fc8fa494958772a2a7302b0354ab16e0b9272b3c3d83cdb5bec5bd1779"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f39e2f3530ed1626c66e7493be7a8423b023ca852aacdc91fb30162c350d2a92"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:716a78a342679cd1177bc8c2fe957e0ab91405bd43a17094324845200b2fddf4"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:177f01eeaa3aee4a5ffb0d1439c5952b53d5010f86e9d2667963e632e30082cc"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:912e95017ff51dc3d7b6e2be158dedc889d9a5cc3382445589ce554f1a34c0ea"}, + {file = "coverage-7.6.8-cp39-cp39-win32.whl", hash = "sha256:4db3ed6a907b555e57cc2e6f14dc3a4c2458cdad8919e40b5357ab9b6db6c43e"}, + {file = "coverage-7.6.8-cp39-cp39-win_amd64.whl", hash = "sha256:428ac484592f780e8cd7b6b14eb568f7c85460c92e2a37cb0c0e5186e1a0d076"}, + {file = "coverage-7.6.8-pp39.pp310-none-any.whl", hash = "sha256:5c52a036535d12590c32c49209e79cabaad9f9ad8aa4cbd875b68c4d67a9cbce"}, + {file = "coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc"}, ] [package.dependencies] @@ -2583,13 +2583,13 @@ dev = ["click", "codecov", "mkdocs-gen-files", "mkdocs-git-authors-plugin", "mkd [[package]] name = "mkdocs-material" -version = "9.5.45" +version = "9.5.46" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.45-py3-none-any.whl", hash = "sha256:a9be237cfd0be14be75f40f1726d83aa3a81ce44808dc3594d47a7a592f44547"}, - {file = "mkdocs_material-9.5.45.tar.gz", hash = "sha256:286489cf0beca4a129d91d59d6417419c63bceed1ce5cd0ec1fc7e1ebffb8189"}, + {file = "mkdocs_material-9.5.46-py3-none-any.whl", hash = "sha256:98f0a2039c62e551a68aad0791a8d41324ff90c03a6e6cea381a384b84908b83"}, + {file = "mkdocs_material-9.5.46.tar.gz", hash = "sha256:ae2043f4238e572f9a40e0b577f50400d6fc31e2fef8ea141800aebf3bd273d7"}, ] [package.dependencies] @@ -5194,4 +5194,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "b1a5cce3381c4b6a115913bdeb69546cbedecc60803e5b33798187aa82641e77" +content-hash = "1acc901866ecfc2c0f3576b9e442d7a3b6e6522cac3d4d1b9301ed4232755cba" diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 7c42c521c0..8b6947b458 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -42,7 +42,7 @@ aws: "amazon-elb" ] # aws.ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports - ec2_sg_high_risk_ports: + ec2_high_risk_ports: [ 25, 110, diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index fc648ffc04..d760702fa8 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -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 diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index 8d5ac97ac4..ff54ccc8d1 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -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) diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 446440b6aa..b060c58e86 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -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() diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 7d6567c5e6..cfc75a2e06 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -7404,25 +7404,6 @@ "aws-us-gov": [] } }, - "nimble": { - "regions": { - "aws": [ - "ap-northeast-1", - "ap-southeast-1", - "ap-southeast-2", - "ca-central-1", - "eu-central-1", - "eu-north-1", - "eu-west-1", - "eu-west-2", - "us-east-1", - "us-east-2", - "us-west-2" - ], - "aws-cn": [], - "aws-us-gov": [] - } - }, "notifications": { "regions": { "aws": [ @@ -9284,10 +9265,7 @@ "us-west-2" ], "aws-cn": [], - "aws-us-gov": [ - "us-gov-east-1", - "us-gov-west-1" - ] + "aws-us-gov": [] } }, "sagemaker-runtime": { @@ -10098,6 +10076,15 @@ ] } }, + "socialmessaging": { + "regions": { + "aws": [ + "eu-central-1" + ], + "aws-cn": [], + "aws-us-gov": [] + } + }, "sqs": { "regions": { "aws": [ diff --git a/prowler/providers/aws/services/rds/rds_service.py b/prowler/providers/aws/services/rds/rds_service.py index b4dd5d66ce..60d24b1cb1 100644 --- a/prowler/providers/aws/services/rds/rds_service.py +++ b/prowler/providers/aws/services/rds/rds_service.py @@ -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"], diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.py b/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.py index d8a6a41da4..d63d1a5e28 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.py +++ b/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.py @@ -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." diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_service.py b/prowler/providers/azure/services/containerregistry/containerregistry_service.py index 6c3840febc..189c89c642 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_service.py +++ b/prowler/providers/azure/services/containerregistry/containerregistry_service.py @@ -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] diff --git a/prowler/providers/gcp/lib/service/service.py b/prowler/providers/gcp/lib/service/service.py index dbb7f22638..9763230153 100644 --- a/prowler/providers/gcp/lib/service/service.py +++ b/prowler/providers/gcp/lib/service/service.py @@ -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" ) diff --git a/prowler/providers/kubernetes/kubernetes_provider.py b/prowler/providers/kubernetes/kubernetes_provider.py index 9182d3a4ca..0f11a41fb0 100644 --- a/prowler/providers/kubernetes/kubernetes_provider.py +++ b/prowler/providers/kubernetes/kubernetes_provider.py @@ -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, diff --git a/pyproject.toml b/pyproject.toml index f0354fdadc..c72a5ab9a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.69" +boto3 = "1.35.71" +botocore = "1.35.71" colorama = "0.4.6" cryptography = "43.0.1" dash = "2.18.2" @@ -75,9 +75,9 @@ 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.7" +coverage = "7.6.8" docker = "7.1.0" flake8 = "7.1.1" freezegun = "1.5.1" @@ -100,7 +100,7 @@ optional = true [tool.poetry.group.docs.dependencies] mkdocs = "1.6.1" mkdocs-git-revision-date-localized-plugin = "1.3.0" -mkdocs-material = "9.5.45" +mkdocs-material = "9.5.46" mkdocs-material-extensions = "1.3.1" [tool.poetry.scripts] diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 98e1e5f43e..f79ef50eff 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -79,7 +79,7 @@ config_aws = { "max_ec2_instance_age_in_days": 180, "ec2_allowed_interface_types": ["api_gateway_managed", "vpc_endpoint"], "ec2_allowed_instance_owners": ["amazon-elb"], - "ec2_sg_high_risk_ports": [ + "ec2_high_risk_ports": [ 25, 110, 135, diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index d769b83404..54b1c011b3 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -42,7 +42,7 @@ aws: "amazon-elb" ] # aws.ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports - ec2_sg_high_risk_ports: + ec2_high_risk_ports: [ 25, 110, diff --git a/tests/lib/check/check_loader_test.py b/tests/lib/check/check_loader_test.py index 72080fc560..a122d76ff5 100644 --- a/tests/lib/check/check_loader_test.py +++ b/tests/lib/check/check_loader_test.py @@ -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, + ) diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 7cf470c7e6..414de90288 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -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): diff --git a/tests/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible_test.py b/tests/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible_test.py index 827c889b73..8239aa5b70 100644 --- a/tests/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible_test.py +++ b/tests/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible_test.py @@ -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=[], diff --git a/tests/providers/azure/services/containerregistry/containerregistry_service_test.py b/tests/providers/azure/services/containerregistry/containerregistry_service_test.py index 31ca7cea9a..b6ff17fb16 100644 --- a/tests/providers/azure/services/containerregistry/containerregistry_service_test.py +++ b/tests/providers/azure/services/containerregistry/containerregistry_service_test.py @@ -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) diff --git a/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py b/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py index 0f1311cf09..f79e7ad725 100644 --- a/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py +++ b/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py @@ -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"], diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index d5ec199b06..d39314d2da 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -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", @@ -89,7 +93,14 @@ export const createNewUser = async ( return parsedResponse; } catch (error) { - return { errors: [{ detail: "Network error or server is unreachable" }] }; + return { + errors: [ + { + source: { pointer: "" }, + detail: "Network error or server is unreachable", + }, + ], + }; } }; @@ -145,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"); } }; diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 9fc89b0907..3b035f84ab 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -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; + } +}; diff --git a/ui/actions/overview/overview.ts b/ui/actions/overview/overview.ts index 8d83ab7088..2095a9f32f 100644 --- a/ui/actions/overview/overview.ts +++ b/ui/actions/overview/overview.ts @@ -39,7 +39,7 @@ export const getProvidersOverview = async ({ const data = await response.json(); const parsedData = parseStringify(data); - revalidatePath("/providers-overview"); + revalidatePath("/"); return parsedData; } catch (error) { // eslint-disable-next-line no-console @@ -47,3 +47,97 @@ export const getProvidersOverview = async ({ return undefined; } }; + +export const getFindingsByStatus = async ({ + page = 1, + query = "", + sort = "", + filters = {}, +}) => { + const session = await auth(); + + if (isNaN(Number(page)) || page < 1) redirect("/"); + + const keyServer = process.env.API_BASE_URL; + const url = new URL(`${keyServer}/overviews/findings`); + + if (page) url.searchParams.append("page[number]", page.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)); + } + }); + + 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 findings severity: ${response.status}`); + } + + const data = await response.json(); + const parsedData = parseStringify(data); + revalidatePath("/"); + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching findings severity overview:", error); + return undefined; + } +}; + +export const getFindingsBySeverity = async ({ + page = 1, + query = "", + sort = "", + filters = {}, +}) => { + const session = await auth(); + + if (isNaN(Number(page)) || page < 1) redirect("/"); + + const keyServer = process.env.API_BASE_URL; + const url = new URL(`${keyServer}/overviews/findings_severity`); + + if (page) url.searchParams.append("page[number]", page.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)); + } + }); + + 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 findings severity: ${response.status}`); + } + + const data = await response.json(); + const parsedData = parseStringify(data); + revalidatePath("/"); + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching findings severity overview:", error); + return undefined; + } +}; diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index fc5cd97bb7..b11841de40 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -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,103 @@ 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), + }; + } +}; + +export const updateCredentialsProvider = async ( + credentialsId: string, + formData: FormData, +) => { + const session = await auth(); + const keyServer = process.env.API_BASE_URL; + const url = new URL(`${keyServer}/providers/secrets/${credentialsId}`); + + const secretName = formData.get("secretName"); + const providerType = formData.get("providerType"); + + const isRole = formData.get("role_arn") !== null; + + let secret = {}; + + if (providerType === "aws") { + if (isRole) { + // Role-based configuration for AWS + secret = { + role_arn: formData.get("role_arn"), + aws_access_key_id: formData.get("aws_access_key_id") || undefined, + aws_secret_access_key: + formData.get("aws_secret_access_key") || undefined, + aws_session_token: formData.get("aws_session_token") || undefined, + session_duration: + parseInt(formData.get("session_duration") as string, 10) || 3600, + external_id: formData.get("external_id") || undefined, + role_session_name: formData.get("role_session_name") || undefined, + }; + } else { + // Static credentials configuration for AWS + secret = { + aws_access_key_id: formData.get("aws_access_key_id"), + aws_secret_access_key: formData.get("aws_secret_access_key"), + aws_session_token: formData.get("aws_session_token") || undefined, + }; + } + } else if (providerType === "azure") { + // Static credentials configuration for Azure + secret = { + client_id: formData.get("client_id"), + client_secret: formData.get("client_secret"), + tenant_id: formData.get("tenant_id"), + }; + } else if (providerType === "gcp") { + // Static credentials configuration for GCP + secret = { + client_id: formData.get("client_id"), + client_secret: formData.get("client_secret"), + refresh_token: formData.get("refresh_token"), + }; + } else if (providerType === "kubernetes") { + // Static credentials configuration for Kubernetes + secret = { + kubeconfig_content: formData.get("kubeconfig_content"), + }; + } + + const bodyData = { + data: { + type: "provider-secrets", + id: credentialsId, + attributes: { + name: secretName, + secret, + }, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers: { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, + }, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + throw new Error(`Failed to update credentials: ${response.statusText}`); + } + + 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), @@ -264,7 +366,7 @@ export const checkConnectionProvider = async (formData: FormData) => { }, }); const data = await response.json(); - await wait(1000); + await wait(2000); revalidatePath("/providers"); return parseStringify(data); } catch (error) { diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 367446daaa..3ac7d897d6 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -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), diff --git a/ui/actions/users/users.ts b/ui/actions/users/users.ts index 30a5b26d1f..cf3397a940 100644 --- a/ui/actions/users/users.ts +++ b/ui/actions/users/users.ts @@ -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; + } +}; diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 38c9800467..558caeaf9b 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -16,44 +16,70 @@ export default async function Compliance({ }: { searchParams: SearchParamsProps; }) { - const scansData = await getScans({}); - const scanList = scansData?.data - .filter( - (scan: any) => - scan.attributes.state === "completed" && - scan.attributes.progress === 100, - ) - .map((scan: any) => ({ - id: scan.id, - name: scan.attributes.name || "Unnamed Scan", - state: scan.attributes.state, - progress: scan.attributes.progress, - })); + let scansData; + let scanList: { + id: string; + name: string; + state: string; + progress: number; + }[] = []; - const selectedScanId = searchParams.scanId || scanList[0]?.id; + try { + scansData = await getScans({}); + scanList = + scansData?.data + ?.filter( + (scan: any) => + scan.attributes.state === "completed" && + scan.attributes.progress === 100, + ) + .map((scan: any) => ({ + id: scan.id, + name: scan.attributes.name || "Unnamed Scan", + state: scan.attributes.state, + progress: scan.attributes.progress, + })) || []; + } catch (error) { + console.error("Error fetching scans data:", error); + } + + const selectedScanId = searchParams.scanId || scanList[0]?.id || null; + + // If there are no scans available, return a message + if (!selectedScanId) { + return ( +
+
No scans available to select.
+
+ ); + } // Fetch compliance data for regions - const compliancesData = await getCompliancesOverview({ - scanId: selectedScanId, - }); - - // Extract unique regions - const regions = compliancesData?.data - ? Array.from( - new Set( - compliancesData.data.map( - (compliance: ComplianceOverviewData) => - compliance.attributes.region as string, + let compliancesData; + let regions: string[] = []; + try { + compliancesData = await getCompliancesOverview({ + scanId: selectedScanId as string, + }); + regions = compliancesData?.data + ? Array.from( + new Set( + compliancesData.data.map( + (compliance: ComplianceOverviewData) => + compliance.attributes.region as string, + ), ), - ), - ) - : []; + ) + : []; + } catch (error) { + console.error("Error fetching compliance data:", error); + } return ( <>
- + }> @@ -68,14 +94,25 @@ const SSRComplianceGrid = async ({ searchParams: SearchParamsProps; }) => { const scanId = searchParams.scanId?.toString() || ""; - const regionFilter = searchParams["filter[region__in]"]?.toString() || ""; // Fetch compliance data - const compliancesData = await getCompliancesOverview({ - scanId, - region: regionFilter, - }); + let compliancesData; + try { + compliancesData = await getCompliancesOverview({ + scanId, + region: regionFilter, + }); + } catch (error) { + console.error("Error fetching compliances overview:", error); + return ( +
+
+ Failed to load compliance data. Please try again later. +
+
+ ); + } // Check if the response contains no data if (!compliancesData || compliancesData?.data?.length === 0) { @@ -103,6 +140,7 @@ const SSRComplianceGrid = async ({ const { attributes } = compliance; const { framework, + version, requirements_status: { passed, total }, } = attributes; @@ -110,6 +148,7 @@ const SSRComplianceGrid = async ({ = { + ...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( - allRegionsAndServices - .map((item: { region: string }) => item.region) - .filter(Boolean) || [], - ), - ); - const uniqueServices = Array.from( - new Set( - allRegionsAndServices - .map((item: { service: string }) => item.service) - .filter(Boolean) || [], - ), - ); + const completedScanIds = + completedScans?.map((scan: ScanProps) => scan.id) || []; return ( <> -
+
@@ -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 = { + ...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); diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index bae0d835c4..ff2ab3d28c 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -1,30 +1,69 @@ import { Spacer } from "@nextui-org/react"; import { Suspense } from "react"; -import { getProvidersOverview } from "@/actions/overview/overview"; +import { getFindings } from "@/actions/findings/findings"; import { + getFindingsBySeverity, + getFindingsByStatus, + getProvidersOverview, +} from "@/actions/overview/overview"; +import { FilterControls } from "@/components/filters"; +import { + FindingsBySeverityChart, + FindingsByStatusChart, + LinkToFindings, ProvidersOverview, + SkeletonFindingsBySeverityChart, + SkeletonFindingsByStatusChart, SkeletonProvidersOverview, } from "@/components/overview"; +import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date"; +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 { createDict } from "@/lib/helper"; +import { FindingProps, SearchParamsProps } from "@/types"; -export default function Home() { +export default function Home({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) { + const searchParamsKey = JSON.stringify(searchParams || {}); return ( <>
-
-
- {/* Providers Overview */} -
+ +
+
+
}>
-
+
+ }> + + +
-
+
+ }> + + +
+ +
+ + } + > + + +
@@ -34,9 +73,118 @@ export default function Home() { const SSRProvidersOverview = async () => { const providersOverview = await getProvidersOverview({}); - if (!providersOverview) { - return

There is no providers overview info available

; - } - - return ; + return ( + <> +

Providers Overview

+ + + ); +}; + +const SSRFindingsByStatus = async ({ + searchParams, +}: { + searchParams: SearchParamsProps | undefined | null; +}) => { + const filters = searchParams + ? Object.fromEntries( + Object.entries(searchParams).filter(([key]) => + key.startsWith("filter["), + ), + ) + : {}; + + const findingsByStatus = await getFindingsByStatus({ filters }); + + return ( + <> +

Findings by Status

+ + + ); +}; + +const SSRFindingsBySeverity = async ({ + searchParams, +}: { + searchParams: SearchParamsProps | undefined | null; +}) => { + const filters = searchParams + ? Object.fromEntries( + Object.entries(searchParams).filter(([key]) => + key.startsWith("filter["), + ), + ) + : {}; + + const findingsBySeverity = await getFindingsBySeverity({ filters }); + + return ( + <> +

Findings by Severity

+ + + ); +}; + +const SSRDataNewFindingsTable = async () => { + const page = 1; + const sort = "severity,updated_at"; + + const defaultFilters = { + "filter[status__in]": "FAIL", + "filter[delta__in]": "new", + }; + + const findingsData = await getFindings({ + query: undefined, + page, + sort, + 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 ( + <> +
+

+ Latest 10 failing findings to date by Severity +

+
+ +
+
+ + + ); }; diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index 0cfb4f8ab2..e09aeae757 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -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 ( <>
- -
{JSON.stringify(session.user, null, 2)}
-
{JSON.stringify(session.userId, null, 2)}
-
{JSON.stringify(session.tenantId, null, 2)}
-
{JSON.stringify(session, null, 2)}
+
+
+
+
+ }> + + +
+
+
+
); } + +const SSRDataUser = async () => { + const userProfile: UserProfileProps = await getProfileInfo(); + + return ( + <> +

User Info

+ + + ); +}; diff --git a/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx index 6a525990fa..fce06d1a88 100644 --- a/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx +++ b/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx @@ -1,35 +1,30 @@ -import { redirect } from "next/navigation"; import React from "react"; import { ViaCredentialsForm, ViaRoleForm, } from "@/components/providers/workflow/forms"; +import { SelectViaAWS } from "@/components/providers/workflow/forms/select-via-aws/select-via-aws"; interface Props { searchParams: { type: string; id: string; via?: string }; } export default function AddCredentialsPage({ searchParams }: Props) { - if ( - !searchParams.type || - !searchParams.id || - (searchParams.type === "aws" && !searchParams.via) - ) { - redirect("/providers/connect-account"); - } - - const useCredentialsForm = - (searchParams.type === "aws" && searchParams.via === "credentials") || - (searchParams.type !== "aws" && !searchParams.via); - - const useRoleForm = - searchParams.type === "aws" && searchParams.via === "role"; - return ( <> - {useCredentialsForm && } - {useRoleForm && } + {searchParams.type === "aws" && !searchParams.via && ( + + )} + + {((searchParams.type === "aws" && searchParams.via === "credentials") || + searchParams.type !== "aws") && ( + + )} + + {searchParams.type === "aws" && searchParams.via === "role" && ( + + )} ); } diff --git a/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx index 13cac50f6d..b3e3e29c44 100644 --- a/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx +++ b/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx @@ -5,7 +5,7 @@ import { getProvider } from "@/actions/providers"; import { TestConnectionForm } from "@/components/providers/workflow/forms"; interface Props { - searchParams: { type: string; id: string }; + searchParams: { type: string; id: string; updated: string }; } export default async function TestConnectionPage({ searchParams }: Props) { @@ -25,7 +25,7 @@ export default async function TestConnectionPage({ searchParams }: Props) { async function SSRTestConnection({ searchParams, }: { - searchParams: { type: string; id: string }; + searchParams: { type: string; id: string; updated: string }; }) { const formData = new FormData(); formData.append("id", searchParams.id); diff --git a/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx new file mode 100644 index 0000000000..c3476099ab --- /dev/null +++ b/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +import { + UpdateViaCredentialsForm, + UpdateViaRoleForm, +} from "@/components/providers/workflow/forms"; +import { SelectViaAWS } from "@/components/providers/workflow/forms/select-via-aws/select-via-aws"; + +interface Props { + searchParams: { type: string; id: string; via?: string }; +} + +export default function UpdateCredentialsPage({ searchParams }: Props) { + return ( + <> + {searchParams.type === "aws" && !searchParams.via && ( + + )} + + {((searchParams.type === "aws" && searchParams.via === "credentials") || + searchParams.type !== "aws") && ( + + )} + + {searchParams.type === "aws" && searchParams.via === "role" && ( + + )} + + ); +} diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 7aae9df08d..5209a488d3 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -31,9 +31,13 @@ export default async function Providers({ - }> - - +
+
+ }> + + +
+
); } diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 5b92328cb8..f90c90a9fd 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -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 ( <>
- - - -
- - { - "use server"; - await getScans({}); - }} - /> -
+ {thereIsNoProviders && ( + <> + + + + )} - - -
-
- }> - - -
-
+ {!thereIsNoProviders && ( + <> + {thereIsNoProvidersConnected ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+
+ + { + "use server"; + await getScans({}); + }} + /> +
+ + }> + + +
+
+ + )} ); } @@ -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 ( ); }; - -// const getExecutingScans = async () => { -// const scansData = await getScans({}); - -// return scansData?.data?.some( -// (scan: ScanProps) => -// scan.attributes.state === "executing" && scan.attributes.progress < 100, -// ); -// }; diff --git a/ui/auth.config.ts b/ui/auth.config.ts index 56c44a14e5..2d5d223938 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -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; diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx index 1f1ec4384f..af731fa60c 100644 --- a/ui/components/auth/oss/auth-form.tsx +++ b/ui/components/auth/oss/auth-form.tsx @@ -1,8 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Icon } from "@iconify/react"; -import { Button, Checkbox, Divider, 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"; @@ -12,20 +11,17 @@ import { NotificationIcon, ProwlerExtended } from "@/components/icons"; import { ThemeSwitch } from "@/components/ThemeSwitch"; import { useToast } from "@/components/ui"; import { CustomButton, CustomInput } from "@/components/ui/custom"; -import { - Form, - FormControl, - FormField, - FormMessage, -} from "@/components/ui/form"; +import { Form } from "@/components/ui/form"; 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(); @@ -38,7 +34,6 @@ export const AuthForm = ({ ...(type === "sign-up" && { name: "", company: "", - termsAndConditions: false, confirmPassword: "", ...(invitationToken && { invitationToken }), }), @@ -54,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) { @@ -62,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", @@ -80,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; @@ -182,7 +183,7 @@ export const AuthForm = ({ isInvalid={!!form.formState.errors.password} /> - {type === "sign-in" && ( + {/* {type === "sign-in" && (
Remember me @@ -191,7 +192,7 @@ export const AuthForm = ({ Forgot password?
- )} + )} */} {type === "sign-up" && ( <> )} - ( - <> - - field.onChange(e.target.checked)} - > - I agree with the  - - Terms - -   and  - - Privacy Policy - - - - - - )} - /> )} @@ -269,7 +243,7 @@ export const AuthForm = ({ - {type === "sign-in" && ( + {/* {type === "sign-in" && ( <>
@@ -299,7 +273,7 @@ export const AuthForm = ({
- )} + )} */} {type === "sign-in" ? (

Need to create an account?  diff --git a/ui/components/charts/SeverityChart.tsx b/ui/components/charts/SeverityChart.tsx deleted file mode 100644 index 4c3060c8bd..0000000000 --- a/ui/components/charts/SeverityChart.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { Bar, BarChart, LabelList, XAxis, YAxis } from "recharts"; - -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart/Chart"; - -const chartData = [ - { severity: "critical", findings: 32, fill: "var(--color-critical)" }, - { severity: "high", findings: 78, fill: "var(--color-high)" }, - { severity: "medium", findings: 117, fill: "var(--color-medium)" }, - { severity: "low", findings: 39, fill: "var(--color-low)" }, -]; - -const chartConfig = { - findings: { - label: "Findings", - }, - critical: { - label: "Critical", - color: "hsl(var(--chart-critical))", - }, - high: { - label: "High", - color: "hsl(var(--chart-fail))", - }, - medium: { - label: "Medium", - color: "hsl(var(--chart-medium))", - }, - low: { - label: "Low", - color: "hsl(var(--chart-low))", - }, -} satisfies ChartConfig; - -export const SeverityChart = () => { - return ( -

- - - - chartConfig[value as keyof typeof chartConfig]?.label - } - /> - - - - } - /> - - - - - - -
- ); -}; diff --git a/ui/components/charts/StatusChart.tsx b/ui/components/charts/StatusChart.tsx deleted file mode 100644 index d906db502e..0000000000 --- a/ui/components/charts/StatusChart.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { Chip, Divider, Spacer } from "@nextui-org/react"; -import { TrendingUp } from "lucide-react"; -import * as React from "react"; -import { Label, Pie, PieChart } from "recharts"; - -import { NotificationIcon, SuccessIcon } from "../icons"; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "../ui"; - -const calculatePercent = ( - chartData: { findings: string; number: number; fill: string }[], -) => { - const total = chartData.reduce((sum, item) => sum + item.number, 0); - - return chartData.map((item) => ({ - ...item, - percent: Math.round((item.number / total) * 100) + "%", - })); -}; - -const chartData = [ - { - findings: "Success", - number: 436, - fill: "var(--color-success)", - }, - { findings: "Fail", number: 293, fill: "var(--color-fail)" }, -]; - -const updatedChartData = calculatePercent(chartData); - -const chartConfig = { - number: { - label: "Findings", - }, - success: { - label: "Success", - color: "hsl(var(--chart-success))", - }, - fail: { - label: "Fail", - color: "hsl(var(--chart-fail))", - }, -} satisfies ChartConfig; - -export function StatusChart() { - const totalVisitors = React.useMemo(() => { - return chartData.reduce((acc, curr) => acc + curr.number, 0); - }, []); - - return ( -
- - - } /> - - - - -
-
- } - color="success" - radius="lg" - size="md" - > - {chartData[0].number} - - - {updatedChartData[0].percent} - -
-
- No change from last scan -
- -
-
- } - color="danger" - radius="lg" - size="md" - > - {chartData[1].number} - - - {updatedChartData[1].percent} - -
-
- +2 findings from last scan -
-
-
-
- ); -} diff --git a/ui/components/charts/index.ts b/ui/components/charts/index.ts deleted file mode 100644 index b4d3debb45..0000000000 --- a/ui/components/charts/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./SeverityChart"; -export * from "./StatusChart"; diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 7fcfdbf5dd..b1b8d8ec10 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -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 = ({ 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 = ({ }; return ( - +
= ({ className="h-10 w-10 min-w-10 rounded-md border-1 border-gray-300 bg-white object-contain p-1" />
-

{title}

+

+ {formatTitle(title)} + {version ? ` - ${version}` : ""} +

{ 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( diff --git a/ui/components/filters/custom-date-picker.tsx b/ui/components/filters/custom-date-picker.tsx index 2a982c1781..4d9708397a 100644 --- a/ui/components/filters/custom-date-picker.tsx +++ b/ui/components/filters/custom-date-picker.tsx @@ -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]); @@ -60,6 +61,8 @@ export const CustomDatePicker = () => {
{ } calendarProps={{ - focusedValue: value, + focusedValue: value || undefined, onFocusChange: setValue, nextButtonProps: { variant: "bordered", diff --git a/ui/components/filters/custom-region-selection.tsx b/ui/components/filters/custom-region-selection.tsx index dd84cb4c1f..f97179a5dd 100644 --- a/ui/components/filters/custom-region-selection.tsx +++ b/ui/components/filters/custom-region-selection.tsx @@ -1,39 +1,34 @@ "use client"; + 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(); + const searchParams = useSearchParams(); + + // Memoize selected keys based on the URL + const selectedKeys = useMemo(() => { + const params = searchParams.get("filter[regions]"); + return params ? params.split(",") : []; + }, [searchParams]); + + const applyRegionFilter = useCallback( + (values: string[]) => { + const params = new URLSearchParams(searchParams.toString()); + if (values.length > 0) { + params.set("filter[regions]", values.join(",")); + } else { + params.delete("filter[regions]"); + } + router.push(`?${params.toString()}`, { scroll: false }); + }, + [router, searchParams], + ); -export const CustomRegionSelection = () => { return ( ); diff --git a/ui/components/filters/custom-search-input.tsx b/ui/components/filters/custom-search-input.tsx index f3d9808125..a5f4519877 100644 --- a/ui/components/filters/custom-search-input.tsx +++ b/ui/components/filters/custom-search-input.tsx @@ -39,8 +39,9 @@ export const CustomSearchInput: React.FC = () => { } onChange={(e) => { diff --git a/ui/components/filters/custom-select-provider.tsx b/ui/components/filters/custom-select-provider.tsx index d2a419bbbb..907e8be10b 100644 --- a/ui/components/filters/custom-select-provider.tsx +++ b/ui/components/filters/custom-select-provider.tsx @@ -66,7 +66,8 @@ export const CustomSelectProvider: React.FC = () => { items={dataInputsProvider} aria-label="Select a Provider" placeholder="Select a provider" - labelPlacement="outside" + label="Provider" + labelPlacement="inside" size="sm" onChange={(e) => { const value = e.target.value; diff --git a/ui/components/filters/data-filters.ts b/ui/components/filters/data-filters.ts index e396791b64..9400dc427b 100644 --- a/ui/components/filters/data-filters.ts +++ b/ui/components/filters/data-filters.ts @@ -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 diff --git a/ui/components/filters/filter-controls.tsx b/ui/components/filters/filter-controls.tsx index 6f08b5c928..7a41bc37d5 100644 --- a/ui/components/filters/filter-controls.tsx +++ b/ui/components/filters/filter-controls.tsx @@ -61,11 +61,11 @@ export const FilterControls: React.FC = ({ className="w-full md:w-fit" onPress={clearAllFilters} variant="dashed" - size="sm" + size="md" endContent={} radius="sm" > - Reset + Clear all filters )}
diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-findings.tsx index d5c06862db..e43cb6ae3e 100644 --- a/ui/components/findings/table/column-findings.tsx +++ b/ui/components/findings/table/column-findings.tsx @@ -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[] = [ + { + id: "moreInfo", + header: "Details", + cell: ({ row }) => { + const searchParams = useSearchParams(); + const findingId = searchParams.get("id"); + const isOpen = findingId === row.original.id; + return ( +
+ } + title="Finding Details" + description="View the finding details" + defaultOpen={isOpen} + > + + +
+ ); + }, + }, { accessorKey: "check", header: ({ column }) => ( - + ), cell: ({ row }) => { const { checktitle } = getFindingsMetadata(row); - return

{checktitle}

; + return ( +

+ {checktitle} +

+ ); }, }, { @@ -94,20 +125,40 @@ export const ColumnFindings: ColumnDef[] = [ }, }, { - accessorKey: "scanName", - header: "Scan Name", + accessorKey: "updated_at", + header: ({ column }) => ( + + ), cell: ({ row }) => { - const name = getScanData(row, "name"); - + const { + attributes: { updated_at }, + } = getFindingsData(row); return ( -

- {typeof name === "string" || typeof name === "number" - ? name - : "Invalid data"} -

+
+ +
); }, }, + // { + // accessorKey: "scanName", + // header: "Scan Name", + // cell: ({ row }) => { + // const name = getScanData(row, "name"); + + // return ( + //

+ // {typeof name === "string" || typeof name === "number" + // ? name + // : "Invalid data"} + //

+ // ); + // }, + // }, { accessorKey: "region", header: "Region", @@ -115,9 +166,9 @@ export const ColumnFindings: ColumnDef[] = [ const region = getResourceData(row, "region"); return ( - <> -
{typeof region === "string" ? region : "Invalid region"}
- +
+ {typeof region === "string" ? region : "Invalid region"} +
); }, }, @@ -130,48 +181,22 @@ export const ColumnFindings: ColumnDef[] = [ }, }, { - 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 ( <> -

- {typeof account === "string" ? account : "Invalid account"} -

+ ); }, }, - { - id: "moreInfo", - header: "Details", - cell: ({ row }) => { - const searchParams = useSearchParams(); - const findingId = searchParams.get("id"); - const isOpen = findingId === row.original.id; - return ( -
- } - title="Finding Details" - description="View the finding details" - defaultOpen={isOpen} - > - - -
- ); - }, - }, - { - id: "actions", - cell: ({ row }) => { - return ; - }, - }, ]; diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index 7f2a84e714..d2febae564 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -48,7 +48,7 @@ export const FindingDetail = ({

- Check Metadata + Finding details

@@ -99,7 +99,7 @@ export const FindingDetail = ({ Object.values(remediation.code).some(Boolean) && (

- Check these links: + Reference Information:

{remediation.code.cli && ( @@ -201,13 +201,13 @@ export const FindingDetail = ({

- Inserted At + First seen

- Updated At + Last seen

diff --git a/ui/components/invitations/invitation-details.tsx b/ui/components/invitations/invitation-details.tsx index f335ad6487..7bdc910db7 100644 --- a/ui/components/invitations/invitation-details.tsx +++ b/ui/components/invitations/invitation-details.tsx @@ -26,7 +26,7 @@ interface InvitationDetailsProps { } export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => { - const baseURL = process.env.SITE_URL || "http://localhost:3000"; + const baseURL = process.env.SITE_URL; const invitationLink = `${baseURL}/sign-up?invitation_token=${attributes.token}`; return (
diff --git a/ui/components/invitations/table/data-table-row-actions.tsx b/ui/components/invitations/table/data-table-row-actions.tsx index 6f60d2e0b7..6878fae08e 100644 --- a/ui/components/invitations/table/data-table-row-actions.tsx +++ b/ui/components/invitations/table/data-table-row-actions.tsx @@ -76,7 +76,7 @@ export function DataTableRowActions({ > ; + 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; + +export const FindingsBySeverityChart = ({ + findingsBySeverity, +}: { + findingsBySeverity: FindingsSeverityOverview; +}) => { + const defaultAttributes = { + critical: 0, + high: 0, + medium: 0, + low: 0, + informational: 0, + }; + + const attributes = findingsBySeverity?.data?.attributes || defaultAttributes; + + const chartData = Object.entries(attributes).map(([severity, findings]) => ({ + severity, + findings, + fill: chartConfig[severity as keyof typeof chartConfig]?.color, + })); + + return ( + + +
+ + + + chartConfig[value as keyof typeof chartConfig]?.label + } + /> + + + + } + /> + { + const severity = data.severity as keyof typeof chartConfig; + const link = chartConfig[severity]?.link; + if (link) { + window.location.href = link; + } + }} + style={{ cursor: "pointer" }} + > + + + + +
+
+
+ ); +}; diff --git a/ui/components/overview/findings-by-severity-chart/skeleton-findings-severity-chart.tsx b/ui/components/overview/findings-by-severity-chart/skeleton-findings-severity-chart.tsx new file mode 100644 index 0000000000..e3eb51b49f --- /dev/null +++ b/ui/components/overview/findings-by-severity-chart/skeleton-findings-severity-chart.tsx @@ -0,0 +1,62 @@ +import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react"; + +export const SkeletonFindingsBySeverityChart = () => { + return ( + + + +
+
+
+ +
+ {/* Critical */} +
+ +
+
+ +
+
+
+ {/* High */} +
+ +
+
+ +
+
+
+ {/* Medium */} +
+ +
+
+ +
+
+
+ {/* Low */} +
+ +
+
+ +
+
+
+ {/* Informational */} +
+ +
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx new file mode 100644 index 0000000000..d8f84ba653 --- /dev/null +++ b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx @@ -0,0 +1,209 @@ +"use client"; + +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"; + +import { NotificationIcon, SuccessIcon } from "@/components/icons"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart/Chart"; + +const calculatePercent = ( + chartData: { findings: string; number: number; fill: string }[], +) => { + const total = chartData.reduce((sum, item) => sum + item.number, 0); + + return chartData.map((item) => ({ + ...item, + percent: total > 0 ? Math.round((item.number / total) * 100) + "%" : "0%", + })); +}; + +interface FindingsByStatusChartProps { + findingsByStatus: { + data: { + attributes: { + fail: number; + pass: number; + pass_new: number; + fail_new: number; + total: number; + }; + }; + }; +} + +const chartConfig = { + number: { + label: "Findings", + }, + success: { + label: "Success", + color: "hsl(var(--chart-success))", + }, + fail: { + label: "Fail", + color: "hsl(var(--chart-fail))", + }, +} satisfies ChartConfig; + +export const FindingsByStatusChart: React.FC = ({ + findingsByStatus, +}) => { + const { + fail = 0, + pass = 0, + pass_new = 0, + fail_new = 0, + } = findingsByStatus?.data?.attributes || {}; + const chartData = [ + { + findings: "Success", + number: pass, + fill: "var(--color-success)", + }, + { + findings: "Fail", + number: fail, + fill: "var(--color-fail)", + }, + ]; + + const updatedChartData = calculatePercent(chartData); + + const totalFindings = useMemo( + () => chartData.reduce((acc, curr) => acc + curr.number, 0), + [chartData], + ); + + const emptyChartData = [ + { + findings: "Empty", + number: 1, + fill: "hsl(var(--nextui-default-200))", + }, + ]; + + return ( + + +
+ + + } /> + 0 ? chartData : emptyChartData} + dataKey="number" + nameKey="findings" + innerRadius={65} + strokeWidth={55} + > + + + + +
+
+
+ + } + color="success" + radius="lg" + size="md" + > + {chartData[0].number} + + {updatedChartData[0].percent} + +
+
+ {pass_new > 0 ? ( + <> + +{pass_new} pass findings from last day{" "} + + + ) : pass_new < 0 ? ( + <>{pass_new} pass findings from last day + ) : ( + "No change from last day" + )} +
+
+ +
+
+ + } + color="danger" + radius="lg" + size="md" + > + {chartData[1].number} + + {updatedChartData[1].percent} + +
+
+ +{fail_new} fail findings from last day{" "} + +
+
+
+
+
+
+ ); +}; diff --git a/ui/components/overview/findings-by-status-chart/skeleton-findings-status-chart.tsx b/ui/components/overview/findings-by-status-chart/skeleton-findings-status-chart.tsx new file mode 100644 index 0000000000..f4b8a9d12b --- /dev/null +++ b/ui/components/overview/findings-by-status-chart/skeleton-findings-status-chart.tsx @@ -0,0 +1,54 @@ +import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react"; + +export const SkeletonFindingsByStatusChart = () => { + return ( + + + +
+
+
+ +
+ {/* Circle Chart Skeleton */} + +
+
+ + {/* Text Details Skeleton */} +
+ {/* Pass Findings */} +
+
+ +
+
+ +
+
+
+ +
+
+
+ + {/* Fail Findings */} +
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+
+ ); +}; diff --git a/ui/components/overview/index.ts b/ui/components/overview/index.ts index eb393767f1..e2beebb034 100644 --- a/ui/components/overview/index.ts +++ b/ui/components/overview/index.ts @@ -1,3 +1,8 @@ export * from "./AttackSurface"; +export * from "./findings-by-severity-chart/findings-by-severity-chart"; +export * from "./findings-by-severity-chart/skeleton-findings-severity-chart"; +export * from "./findings-by-status-chart/findings-by-status-chart"; +export * from "./findings-by-status-chart/skeleton-findings-status-chart"; +export * from "./new-findings-table/link-to-findings/link-to-findings"; export * from "./provider-overview/provider-overview"; export * from "./provider-overview/skeleton-provider-overview"; diff --git a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx new file mode 100644 index 0000000000..2c86db8fd3 --- /dev/null +++ b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { CustomButton } from "@/components/ui/custom"; + +export const LinkToFindings = () => { + return ( +
+ + Check out on Findings + +
+ ); +}; diff --git a/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx b/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx new file mode 100644 index 0000000000..998480698e --- /dev/null +++ b/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +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 { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; +import { FindingProps } from "@/types"; + +const getFindingsData = (row: { original: FindingProps }) => { + return row.original; +}; + +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[] = [ + { + id: "moreInfo", + header: "Details", + cell: ({ row }) => { + const searchParams = useSearchParams(); + const findingId = searchParams.get("id"); + const isOpen = findingId === row.original.id; + return ( +
+ } + title="Finding Details" + description="View the finding details" + defaultOpen={isOpen} + > + + +
+ ); + }, + }, + { + accessorKey: "check", + header: "Finding", + cell: ({ row }) => { + const { checktitle } = getFindingsMetadata(row); + return ( +

+ {checktitle} +

+ ); + }, + }, + { + accessorKey: "severity", + header: "Severity", + cell: ({ row }) => { + const { + attributes: { severity }, + } = getFindingsData(row); + return ; + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const { + attributes: { status }, + } = getFindingsData(row); + + return ; + }, + }, + { + accessorKey: "updated_at", + header: "Last seen", + cell: ({ row }) => { + const { + attributes: { updated_at }, + } = getFindingsData(row); + return ( +
+ +
+ ); + }, + }, + { + accessorKey: "region", + header: "Region", + cell: ({ row }) => { + const region = getResourceData(row, "region"); + + return ( +
+ {typeof region === "string" ? region : "Invalid region"} +
+ ); + }, + }, + { + accessorKey: "service", + header: "Service", + cell: ({ row }) => { + const { servicename } = getFindingsMetadata(row); + return

{servicename}

; + }, + }, + { + accessorKey: "cloudProvider", + header: "Cloud provider", + cell: ({ row }) => { + const provider = getProviderData(row, "provider"); + const alias = getProviderData(row, "alias"); + const uid = getProviderData(row, "uid"); + + return ( + <> + + + ); + }, + }, +]; diff --git a/ui/components/overview/new-findings-table/table/finding-detail.tsx b/ui/components/overview/new-findings-table/table/finding-detail.tsx new file mode 100644 index 0000000000..d2febae564 --- /dev/null +++ b/ui/components/overview/new-findings-table/table/finding-detail.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { Snippet } from "@nextui-org/react"; +import Link from "next/link"; + +import { SnippetId } from "@/components/ui/entities"; +import { DateWithTime } from "@/components/ui/entities/date-with-time"; +import { SeverityBadge } from "@/components/ui/table/severity-badge"; +import { FindingProps } from "@/types"; + +export const FindingDetail = ({ + findingDetails, +}: { + findingDetails: FindingProps; +}) => { + const finding = findingDetails; + const attributes = finding.attributes; + const resource = finding.relationships.resource.attributes; + + const remediation = attributes.check_metadata.remediation; + + return ( +
+ {/* Header */} +
+
+

+ {attributes.check_metadata.checktitle} +

+

+ {resource.service} +

+
+
+ {attributes.status} +
+
+ + {/* Check Metadata */} +
+
+

+ Finding details +

+ +
+ {attributes.status === "FAIL" && ( + +

+ Risk +

+

+ {attributes.check_metadata.risk} +

+
+ )} + +
+

+ Description +

+

+ {attributes.check_metadata.description} +

+
+ +
+

+ Remediation +

+
+ {remediation.recommendation && ( + <> +

Recommendation:

+

{remediation.recommendation.text}

+ + Learn more + + + )} + {remediation.code && + Object.values(remediation.code).some(Boolean) && ( +
+

+ Reference Information: +

+
+ {remediation.code.cli && ( +
+

CLI Command:

+ +

+ {remediation.code.cli} +

+
+
+ )} +
+ {Object.entries(remediation.code) + .filter(([key]) => key !== "cli") + .map(([key, value]) => + value ? ( + + {key === "other" + ? "External doc" + : key.charAt(0).toUpperCase() + + key.slice(1).toLowerCase()} + + ) : null, + )} +
+
+
+ )} +
+
+
+ + {/* Resources Section */} +
+

+ Resource Details +

+
+
+

+ Resource ID +

+ +

{resource.uid}

+
+
+
+

+ Resource Name +

+

+ {resource.name} +

+
+
+

+ Region +

+

+ {resource.region} +

+
+
+

+ Resource Type +

+

+ {resource.type} +

+
+
+

+ Severity +

+ +
+ {resource.tags && + Object.entries(resource.tags).map(([key, value]) => ( +
+

+ Tag: {key} +

+ +

{value}

+
+
+ ))} +
+
+

+ First seen +

+ +
+
+

+ Last seen +

+ +
+
+
+
+
+ ); +}; diff --git a/ui/components/overview/new-findings-table/table/index.ts b/ui/components/overview/new-findings-table/table/index.ts new file mode 100644 index 0000000000..5f8e56989e --- /dev/null +++ b/ui/components/overview/new-findings-table/table/index.ts @@ -0,0 +1,2 @@ +export * from "./column-new-findings-to-date"; +export * from "./skeleton-table-new-findings"; diff --git a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx new file mode 100644 index 0000000000..6a24119681 --- /dev/null +++ b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx @@ -0,0 +1,65 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonTableNewFindings = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(3)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/overview/provider-overview/provider-overview.tsx b/ui/components/overview/provider-overview/provider-overview.tsx index cd69f02595..48333c5fbc 100644 --- a/ui/components/overview/provider-overview/provider-overview.tsx +++ b/ui/components/overview/provider-overview/provider-overview.tsx @@ -1,6 +1,6 @@ "use client"; -import { Card, CardBody, CardHeader } from "@nextui-org/react"; +import { Card, CardBody } from "@nextui-org/react"; import { AddIcon } from "@/components/icons/Icons"; import { @@ -17,11 +17,6 @@ export const ProvidersOverview = ({ }: { providersOverview: ProviderOverviewProps; }) => { - console.log(providersOverview); - if (!providersOverview || !Array.isArray(providersOverview.data)) { - return

No provider data available

; - } - const calculatePassingPercentage = (pass: number, total: number) => total > 0 ? ((pass / total) * 100).toFixed(2) : "0.00"; @@ -47,13 +42,59 @@ export const ProvidersOverview = ({ { id: "kubernetes", name: "Kubernetes" }, ]; + if (!providersOverview || !Array.isArray(providersOverview.data)) { + return ( + + +
+
+ Provider + + Percent + Passing + + + Failing + Checks + + + Total + Resources + +
+ + {providers.map((providerTemplate) => ( +
+ + {renderProviderBadge(providerTemplate.id)} + + 0.00% + - + - +
+ ))} + +
+ + Total + + 0.00% + - + - +
+
+
+
+ ); + } + return ( - - -

Providers Overview

-
+ -
+
Provider @@ -134,7 +175,7 @@ export const ProvidersOverview = ({
{ + return ( + + View Scan Jobs + + ); +}; diff --git a/ui/components/providers/provider-info.tsx b/ui/components/providers/provider-info.tsx index 9d6a11c371..9eea6cdf74 100644 --- a/ui/components/providers/provider-info.tsx +++ b/ui/components/providers/provider-info.tsx @@ -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 = ({ connected, provider, providerAlias, + providerUID, }) => { const getIcon = () => { switch (connected) { @@ -44,32 +41,20 @@ export const ProviderInfo: React.FC = ({ } }; - const getProviderLogo = () => { - switch (provider) { - case "aws": - return ; - case "azure": - return ; - case "gcp": - return ; - case "kubernetes": - return ; - default: - return null; - } - }; - return ( -
-
-
-
{getProviderLogo()}
-
{getIcon()}
-
- - {providerAlias} +
+
+
+
+ + {getProviderLogo(provider)} - {/* */} +
+
{getIcon()}
+ + {providerAlias || providerUID} + +
diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 1680fd8000..ef7fbdfc9b 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -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[] = [ - // { - // header: " ", - // cell: ({ row }) =>

{row.index + 1}

, - // }, { accessorKey: "account", header: ({ column }) => ( @@ -25,17 +22,28 @@ export const ColumnProviders: ColumnDef[] = [ ), cell: ({ row }) => { const { - attributes: { connection, provider, alias }, + attributes: { connection, provider, alias, uid }, } = getProviderData(row); return ( ); }, }, + { + accessorKey: "scanJobs", + header: "Scan Jobs", + cell: ({ row }) => { + const { + attributes: { uid }, + } = getProviderData(row); + return ; + }, + }, { accessorKey: "uid", header: ({ column }) => ( @@ -48,30 +56,6 @@ export const ColumnProviders: ColumnDef[] = [ return ; }, }, - { - accessorKey: "status", - header: "Scan Status", - cell: () => { - // Temporarily overwriting the value until the API is functional. - return ; - }, - }, - { - accessorKey: "lastScan", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { updated_at }, - } = getProviderData(row); - return ; - }, - }, { accessorKey: "added", header: ({ column }) => ( diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 29d0bc127e..7cc95cada5 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -17,6 +17,7 @@ import { Row } from "@tanstack/react-table"; import clsx from "clsx"; import { useState } from "react"; +import { checkConnectionProvider } from "@/actions/providers/providers"; import { VerticalDotsIcon } from "@/components/icons"; import { CustomAlertModal } from "@/components/ui/custom"; @@ -37,13 +38,21 @@ export function DataTableRowActions({ const providerId = (row.original as { id: string }).id; const providerType = (row.original as any).attributes?.provider; const providerAlias = (row.original as any).attributes?.alias; + const providerSecretId = + (row.original as any).relationships?.secret?.data?.id || null; + + const handleTestConnection = async () => { + const formData = new FormData(); + formData.append("providerId", providerId); + await checkConnectionProvider(formData); + }; + return ( <> ({ > } + > + Update Credentials + + } + onClick={handleTestConnection} > Test Connection @@ -93,7 +111,7 @@ export function DataTableRowActions({ startContent={} onClick={() => setIsEditOpen(true)} > - Edit Provider + Edit Provider Alias diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index 54acff1491..5c0fbf71ac 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -1,20 +1,24 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ChevronLeftIcon, ChevronRightIcon, SaveIcon } from "lucide-react"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod"; import { useToast } from "@/components/ui"; import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { + getProviderLogo, + getProviderName, + ProviderType, +} from "@/components/ui/entities"; import { Form } from "@/components/ui/form"; import { addProvider } from "../../../../actions/providers/providers"; import { addProviderFormSchema, ApiError } from "../../../../types"; import { RadioGroupProvider } from "../../radio-group-provider"; -import { RadioGroupAWSViaCredentialsForm } from "./radio-group-aws-via-credentials-form"; export type FormValues = z.infer; @@ -31,87 +35,84 @@ export const ConnectAccountForm = () => { providerType: undefined, providerUid: "", providerAlias: "", - awsCredentialsType: "", }, }); + const providerType = form.watch("providerType"); const isLoading = form.formState.isSubmitting; const onSubmitClient = async (values: FormValues) => { const formValues = { ...values }; - // If providerAlias is empty, set default value - if (!formValues.providerAlias.trim()) { - const date = new Date(); - const month = (date.getMonth() + 1).toString().padStart(2, "0"); - const day = date.getDate().toString().padStart(2, "0"); - const year = date.getFullYear(); - formValues.providerAlias = `${formValues.providerType}:${month}/${day}/${year}`; - } - const formData = new FormData(); - Object.entries(formValues).forEach( ([key, value]) => value !== undefined && formData.append(key, value), ); - const data = await addProvider(formData); + try { + const data = await addProvider(formData); - if (data?.errors && data.errors.length > 0) { - data.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; - const pointer = error.source?.pointer; + if (data?.errors && data.errors.length > 0) { + // Handle server-side validation errors + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + const pointer = error.source?.pointer; - switch (pointer) { - case "/data/attributes/provider": - form.setError("providerType", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/uid": - case "/data/attributes/__all__": - form.setError("providerUid", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/alias": - form.setError("providerAlias", { - type: "server", - message: errorMessage, - }); - break; - default: - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } + switch (pointer) { + case "/data/attributes/provider": + form.setError("providerType", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/uid": + case "/data/attributes/__all__": + form.setError("providerUid", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/alias": + form.setError("providerAlias", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + return; + } else { + // Go to the next step after successful submission + const { + id, + attributes: { provider: providerType }, + } = data.data; + + router.push(`/providers/add-credentials?type=${providerType}&id=${id}`); + } + } catch (error: any) { + console.error("Error during submission:", error); + toast({ + variant: "destructive", + title: "Submission Error", + description: error.message || "Something went wrong. Please try again.", }); - setPrevStep(1); - } else { - const { - id, - attributes: { provider: providerType }, - } = data.data; - const credentialsParam = values.awsCredentialsType - ? `&via=${values.awsCredentialsType}` - : ""; - router.push( - `/providers/add-credentials?type=${providerType}&id=${id}${credentialsParam}`, - ); } }; - const handleNextStep = () => { - setPrevStep((prev) => prev + 1); - }; + const handleBackStep = () => setPrevStep((prev) => prev - 1); - const handleBackStep = () => { - setPrevStep((prev) => prev - 1); - }; + useEffect(() => { + if (providerType) { + setPrevStep(2); + } + }, [providerType]); return (
@@ -119,53 +120,52 @@ export const ConnectAccountForm = () => { onSubmit={form.handleSubmit(onSubmitClient)} className="flex flex-col space-y-4" > + {/* Step 1: Provider selection */} {prevStep === 1 && ( + + )} + {/* Step 2: UID, alias, and credentials (if AWS) */} + {prevStep === 2 && ( <> - {/* Select a provider */} - - {/* Provider UID */} +
+ {providerType && getProviderLogo(providerType as ProviderType)} + + {providerType + ? getProviderName(providerType as ProviderType) + : "Unknown Provider"} + +
- {/* Provider alias */} )} - - {prevStep === 2 && ( - <> - {/* Select AWS credentials type */} - - - )} - + {/* Navigation buttons */}
+ {/* Show "Back" button only in Step 2 */} {prevStep === 2 && ( { Back )} - - - ) - } - endContent={ - !isLoading && - prevStep === 1 && - providerType === "aws" && - } - onPress={() => { - if (prevStep === 1 && providerType === "aws") { - handleNextStep(); - } else { - form.handleSubmit(onSubmitClient)(); - } - }} - > - {isLoading ? ( - <>Loading - ) : ( - - {prevStep === 1 && providerType === "aws" ? "Next" : "Save"} - - )} - + {/* Show "Next" button in Step 2 */} + {prevStep === 2 && ( + } + > + {isLoading ? <>Loading : Next} + + )}
diff --git a/ui/components/providers/workflow/forms/index.ts b/ui/components/providers/workflow/forms/index.ts index be1bb3277d..d7f0d07134 100644 --- a/ui/components/providers/workflow/forms/index.ts +++ b/ui/components/providers/workflow/forms/index.ts @@ -2,5 +2,7 @@ export * from "./connect-account-form"; export * from "./launch-scan-form"; export * from "./radio-group-aws-via-credentials-form"; export * from "./test-connection-form"; +export * from "./update-via-credentials-form"; +export * from "./update-via-role-form"; export * from "./via-credentials-form"; export * from "./via-role-form"; diff --git a/ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx b/ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx index 7a870c79ff..bb53c5a5b9 100644 --- a/ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx @@ -7,18 +7,18 @@ import { Control, Controller } from "react-hook-form"; import { CustomRadio } from "@/components/ui/custom"; import { FormMessage } from "@/components/ui/form"; -import { FormValues } from "./connect-account-form"; - type RadioGroupAWSViaCredentialsFormProps = { - control: Control; + control: Control; isInvalid: boolean; errorMessage?: string; + onChange?: (value: string) => void; }; export const RadioGroupAWSViaCredentialsForm = ({ control, isInvalid, errorMessage, + onChange, }: RadioGroupAWSViaCredentialsFormProps) => { return ( { + field.onChange(value); + if (onChange) { + onChange(value); + } + }} >
Using IAM Role diff --git a/ui/components/providers/workflow/forms/select-via-aws/index.ts b/ui/components/providers/workflow/forms/select-via-aws/index.ts new file mode 100644 index 0000000000..8dd9822258 --- /dev/null +++ b/ui/components/providers/workflow/forms/select-via-aws/index.ts @@ -0,0 +1 @@ +export * from "./select-via-aws"; diff --git a/ui/components/providers/workflow/forms/select-via-aws/select-via-aws.tsx b/ui/components/providers/workflow/forms/select-via-aws/select-via-aws.tsx new file mode 100644 index 0000000000..59197bb40e --- /dev/null +++ b/ui/components/providers/workflow/forms/select-via-aws/select-via-aws.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; + +import { Form } from "@/components/ui/form"; + +import { RadioGroupAWSViaCredentialsForm } from "../radio-group-aws-via-credentials-form"; + +interface SelectViaAWSProps { + initialVia?: string; +} + +export const SelectViaAWS = ({ initialVia }: SelectViaAWSProps) => { + const router = useRouter(); + const form = useForm({ + defaultValues: { + awsCredentialsType: initialVia || "", + }, + }); + + const handleSelectionChange = (value: string) => { + const url = new URL(window.location.href); + url.searchParams.set("via", value); + router.push(url.toString()); + }; + + return ( +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx index 6431c1a814..41e5ebed66 100644 --- a/ui/components/providers/workflow/forms/test-connection-form.tsx +++ b/ui/components/providers/workflow/forms/test-connection-form.tsx @@ -12,9 +12,9 @@ import { checkConnectionProvider, deleteCredentials, } from "@/actions/providers"; -import { scanOnDemand } from "@/actions/scans"; +import { scheduleDaily } from "@/actions/scans"; import { getTask } from "@/actions/task/tasks"; -import { CheckIcon, SaveIcon } from "@/components/icons"; +import { CheckIcon, RocketIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { CustomButton } from "@/components/ui/custom"; import { Form } from "@/components/ui/form"; @@ -29,12 +29,13 @@ export const TestConnectionForm = ({ searchParams, providerData, }: { - searchParams: { type: string; id: string }; + searchParams: { type: string; id: string; updated: string }; providerData: { data: { id: string; type: string; attributes: { + uid: string; connection: { connected: boolean | null; last_checked_at: string | null; @@ -58,7 +59,6 @@ export const TestConnectionForm = ({ const router = useRouter(); const providerType = searchParams.type; const providerId = searchParams.id; - console.log({ providerData }, "providerData from test connection form"); const formSchema = testConnectionFormSchema; const [apiErrorMessage, setApiErrorMessage] = useState(null); const [connectionStatus, setConnectionStatus] = useState<{ @@ -75,9 +75,9 @@ export const TestConnectionForm = ({ }); const isLoading = form.formState.isSubmitting; + const isUpdated = searchParams?.updated === "true"; const onSubmitClient = async (values: FormValues) => { - console.log({ values }, "values from test connection form"); const formData = new FormData(); formData.append("providerId", values.providerId); @@ -117,8 +117,7 @@ export const TestConnectionForm = ({ if (connected) { try { - const data = await scanOnDemand(formData); - + const data = await scheduleDaily(formData); if (data.error) { setApiErrorMessage(data.error); form.setError("providerId", { @@ -126,9 +125,19 @@ export const TestConnectionForm = ({ message: data.error, }); } else { - router.push( - `/providers/launch-scan?type=${providerType}&id=${providerId}`, - ); + const urlParams = new URLSearchParams(window.location.search); + const isUpdated = urlParams.get("updated") === "true"; + + if (!isUpdated) { + router.push( + `/providers/launch-scan?type=${providerType}&id=${providerId}`, + ); + } else { + setConnectionStatus({ + connected: true, + error: null, + }); + } } } catch (error) { form.setError("providerId", { @@ -175,6 +184,7 @@ export const TestConnectionForm = ({ `/providers/add-credentials?type=${providerType}&id=${providerId}`, ); } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to delete credentials:", error); } finally { setIsResettingCredentials(false); @@ -200,7 +210,7 @@ export const TestConnectionForm = ({ {apiErrorMessage && (
-

{`Provider ID ${apiErrorMessage.toLowerCase()}. Please check and try again.`}

+

{`Provider ID ${apiErrorMessage?.toLowerCase()}. Please check and try again.`}

)} @@ -230,8 +240,15 @@ export const TestConnectionForm = ({ connected={providerData.data.attributes.connection.connected} provider={providerData.data.attributes.provider} providerAlias={providerData.data.attributes.alias} + providerUID={providerData.data.attributes.uid} /> + {!isResettingCredentials && !connectionStatus?.error && ( +

+ Test connection and launch scan +

+ )} +
@@ -248,7 +265,7 @@ export const TestConnectionForm = ({ ) : connectionStatus?.error ? ( router.back() : onResetCredentials} type="button" ariaLabel={"Save"} className="w-1/2" @@ -262,21 +279,38 @@ export const TestConnectionForm = ({ {isResettingCredentials ? ( <>Loading ) : ( - Reset credentials + + {isUpdated ? "Update credentials" : "Reset credentials"} + )} ) : ( router.push("/providers") + : undefined + } ariaLabel={"Save"} className="w-1/2" variant="solid" color="action" size="lg" isLoading={isLoading} - startContent={!isLoading && } + endContent={!isLoading && } > - {isLoading ? <>Loading : Test connection} + {isLoading ? ( + <>Loading + ) : ( + + {isUpdated && connectionStatus?.connected + ? "Go to providers" + : "Launch"} + + )} )}
diff --git a/ui/components/providers/workflow/forms/update-via-credentials-form.tsx b/ui/components/providers/workflow/forms/update-via-credentials-form.tsx new file mode 100644 index 0000000000..c327965591 --- /dev/null +++ b/ui/components/providers/workflow/forms/update-via-credentials-form.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Control, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { updateCredentialsProvider } from "@/actions/providers/providers"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { getProviderLogo } from "@/components/ui/entities"; +import { getProviderName } from "@/components/ui/entities"; +import { ProviderType } from "@/components/ui/entities"; +import { Form } from "@/components/ui/form"; +import { + addCredentialsFormSchema, + ApiError, + AWSCredentials, + AzureCredentials, + GCPCredentials, + KubernetesCredentials, +} from "@/types"; + +import { AWScredentialsForm } from "./via-credentials/aws-credentials-form"; +import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form"; +import { GCPcredentialsForm } from "./via-credentials/gcp-credentials-form"; +import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form"; + +type CredentialsFormSchema = z.infer< + ReturnType +>; + +// Add this type intersection to include all fields +type FormType = CredentialsFormSchema & + AWSCredentials & + AzureCredentials & + GCPCredentials & + KubernetesCredentials; + +export const UpdateViaCredentialsForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string; secretId?: string }; +}) => { + const router = useRouter(); + const { toast } = useToast(); + + const searchParamsObj = useSearchParams(); + + // Handler for back button + const handleBackStep = () => { + const currentParams = new URLSearchParams(window.location.search); + currentParams.delete("via"); + router.push(`?${currentParams.toString()}`); + }; + + const providerType = searchParams.type; + const providerId = searchParams.id; + const providerSecretId = searchParams.secretId || ""; + const formSchema = addCredentialsFormSchema(providerType); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + ...(providerType === "aws" + ? { + aws_access_key_id: "", + aws_secret_access_key: "", + aws_session_token: "", + } + : providerType === "azure" + ? { + client_id: "", + client_secret: "", + tenant_id: "", + } + : providerType === "gcp" + ? { + client_id: "", + client_secret: "", + refresh_token: "", + } + : providerType === "kubernetes" + ? { + kubeconfig_content: "", + } + : {}), + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormType) => { + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + + const data = await updateCredentialsProvider(providerSecretId, formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/secret/aws_access_key_id": + form.setError("aws_access_key_id", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/aws_secret_access_key": + form.setError("aws_secret_access_key", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/aws_session_token": + form.setError("aws_session_token", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/client_id": + form.setError("client_id", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/client_secret": + form.setError("client_secret", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/tenant_id": + form.setError("tenant_id", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/kubeconfig_content": + form.setError("kubeconfig_content", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/name": + form.setError("secretName", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + router.push( + `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`, + ); + } + }; + + return ( +
+ + + + +
+ {providerType && getProviderLogo(providerType as ProviderType)} + + {providerType + ? getProviderName(providerType as ProviderType) + : "Unknown Provider"} + +
+ + {providerType === "aws" && ( + } + /> + )} + {providerType === "azure" && ( + } + /> + )} + {providerType === "gcp" && ( + } + /> + )} + {providerType === "kubernetes" && ( + } + /> + )} + +
+ {searchParamsObj.get("via") === "credentials" && ( + } + isDisabled={isLoading} + > + Back + + )} + } + > + {isLoading ? <>Loading : Next} + +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/update-via-role-form.tsx b/ui/components/providers/workflow/forms/update-via-role-form.tsx new file mode 100644 index 0000000000..ee57dc0759 --- /dev/null +++ b/ui/components/providers/workflow/forms/update-via-role-form.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Control, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { updateCredentialsProvider } from "@/actions/providers/providers"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { + addCredentialsRoleFormSchema, + ApiError, + AWSCredentialsRole, +} from "@/types"; + +import { AWSCredentialsRoleForm } from "./via-role/aws-role-form"; + +export const UpdateViaRoleForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string; secretId?: string }; +}) => { + const router = useRouter(); + const { toast } = useToast(); + + const searchParamsObj = useSearchParams(); + + // Handler for back button + const handleBackStep = () => { + const currentParams = new URLSearchParams(window.location.search); + currentParams.delete("via"); + router.push(`?${currentParams.toString()}`); + }; + + const providerType = searchParams.type; + const providerId = searchParams.id; + const providerSecretId = searchParams.secretId || ""; + + const formSchema = addCredentialsRoleFormSchema(providerType); + type FormSchemaType = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + ...(providerType === "aws" + ? { + role_arn: "", + aws_access_key_id: "", + aws_secret_access_key: "", + aws_session_token: "", + session_duration: 3600, + external_id: "", + role_session_name: "", + } + : {}), + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormSchemaType) => { + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => + value !== undefined && formData.append(key, String(value)), + ); + + const data = await updateCredentialsProvider(providerSecretId, formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/secret/role_arn": + form.setError("role_arn" as keyof FormSchemaType, { + type: "server", + message: errorMessage, + }); + break; + + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + router.push( + `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`, + ); + } + }; + + return ( +
+ + + + + {providerType === "aws" && ( + } + /> + )} + +
+ {searchParamsObj.get("via") === "role" && ( + } + isDisabled={isLoading} + > + Back + + )} + } + > + {isLoading ? <>Loading : Next} + +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials-form.tsx index 176ed40a00..6c0bd0fe37 100644 --- a/ui/components/providers/workflow/forms/via-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials-form.tsx @@ -1,14 +1,17 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { SaveIcon } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; import { Control, useForm } from "react-hook-form"; import * as z from "zod"; import { addCredentialsProvider } from "@/actions/providers/providers"; import { useToast } from "@/components/ui"; import { CustomButton } from "@/components/ui/custom"; +import { getProviderLogo } from "@/components/ui/entities"; +import { getProviderName } from "@/components/ui/entities"; +import { ProviderType } from "@/components/ui/entities"; import { Form } from "@/components/ui/form"; import { addCredentialsFormSchema, @@ -43,6 +46,15 @@ export const ViaCredentialsForm = ({ const router = useRouter(); const { toast } = useToast(); + const searchParamsObj = useSearchParams(); + + // Handler for back button + const handleBackStep = () => { + const currentParams = new URLSearchParams(window.location.search); + currentParams.delete("via"); + router.push(`?${currentParams.toString()}`); + }; + const providerType = searchParams.type; const providerId = searchParams.id; const formSchema = addCredentialsFormSchema(providerType); @@ -81,7 +93,6 @@ export const ViaCredentialsForm = ({ const isLoading = form.formState.isSubmitting; const onSubmitClient = async (values: FormType) => { - console.log("via credentials form", values); const formData = new FormData(); Object.entries(values).forEach( @@ -166,6 +177,15 @@ export const ViaCredentialsForm = ({ +
+ {providerType && getProviderLogo(providerType as ProviderType)} + + {providerType + ? getProviderName(providerType as ProviderType) + : "Unknown Provider"} + +
+ {providerType === "aws" && ( } @@ -188,6 +208,21 @@ export const ViaCredentialsForm = ({ )}
+ {searchParamsObj.get("via") === "credentials" && ( + } + isDisabled={isLoading} + > + Back + + )} } + endContent={!isLoading && } > - {isLoading ? <>Loading : Save} + {isLoading ? <>Loading : Next}
diff --git a/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx index 0725d134eb..3e6caeca88 100644 --- a/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx @@ -1,6 +1,6 @@ import { Control } from "react-hook-form"; -import { CustomInput } from "@/components/ui/custom"; +import { CustomTextarea } from "@/components/ui/custom"; import { KubernetesCredentials } from "@/types"; export const KubernetesCredentialsForm = ({ @@ -15,17 +15,17 @@ export const KubernetesCredentialsForm = ({ Connect via Credentials
- Please provide the information for your Kubernetes credentials. + Please provide the kubeconfig content for your Kubernetes credentials.
- diff --git a/ui/components/providers/workflow/forms/via-role-form.tsx b/ui/components/providers/workflow/forms/via-role-form.tsx index 39ba301447..3a38cb1808 100644 --- a/ui/components/providers/workflow/forms/via-role-form.tsx +++ b/ui/components/providers/workflow/forms/via-role-form.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { SaveIcon } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; import { Control, useForm } from "react-hook-form"; import * as z from "zod"; @@ -26,6 +26,15 @@ export const ViaRoleForm = ({ const router = useRouter(); const { toast } = useToast(); + const searchParamsObj = useSearchParams(); + + // Handler for back button + const handleBackStep = () => { + const currentParams = new URLSearchParams(window.location.search); + currentParams.delete("via"); + router.push(`?${currentParams.toString()}`); + }; + const providerType = searchParams.type; const providerId = searchParams.id; @@ -54,7 +63,6 @@ export const ViaRoleForm = ({ const isLoading = form.formState.isSubmitting; const onSubmitClient = async (values: FormSchemaType) => { - console.log("via ROLE form", values); const formData = new FormData(); Object.entries(values).forEach( @@ -106,6 +114,21 @@ export const ViaRoleForm = ({ )}
+ {searchParamsObj.get("via") === "role" && ( + } + isDisabled={isLoading} + > + Back + + )} } + endContent={!isLoading && } > - {isLoading ? <>Loading : Save} + {isLoading ? <>Loading : Next}
diff --git a/ui/components/providers/workflow/workflow-add-provider.tsx b/ui/components/providers/workflow/workflow-add-provider.tsx index 1f2e631f06..6bae1f2dde 100644 --- a/ui/components/providers/workflow/workflow-add-provider.tsx +++ b/ui/components/providers/workflow/workflow-add-provider.tsx @@ -25,9 +25,9 @@ const steps = [ href: "/providers/test-connection", }, { - title: "Launch scan", + title: "Success", description: - "Launch the scan now or schedule it for a later date and time.", + "Your cloud account has been successfully connected and the scan has been launched.", href: "/providers/launch-scan", }, ]; diff --git a/ui/components/scans/index.ts b/ui/components/scans/index.ts index 7d435fad43..aa42edfe3c 100644 --- a/ui/components/scans/index.ts +++ b/ui/components/scans/index.ts @@ -1 +1,4 @@ export * from "./button-refresh-data"; +export * from "./link-to-findings-from-scan"; +export * from "./no-providers-added"; +export * from "./no-providers-connected"; diff --git a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx index a19acfea42..20088cad93 100644 --- a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx +++ b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx @@ -5,7 +5,7 @@ import { useForm } from "react-hook-form"; import * as z from "zod"; import { scanOnDemand } from "@/actions/scans"; -import { RocketIcon, ScheduleIcon } from "@/components/icons"; +import { RocketIcon } from "@/components/icons"; import { CustomButton, CustomInput } from "@/components/ui/custom"; import { Form } from "@/components/ui/form"; import { toast } from "@/components/ui/toast"; @@ -79,26 +79,26 @@ export const LaunchScanWorkflow = ({ onSubmit={form.handleSubmit(onSubmitClient)} className="flex flex-col space-y-4" > -
-
-
-

- Launch Scan -

- -
- - - {form.watch("providerId") && ( +
+
+

+ Launch Scan +

+ +
+ + {form.watch("providerId") && ( + <> - )} - -
- + +
+ } + > + {isLoading ? <>Loading : Start now} + + form.reset()} + className="w-fit border-gray-200 bg-transparent" + ariaLabel="Clear form" + variant="bordered" + size="md" + radius="lg" + > + Cancel + +
+
+ + )} +
+ {/*
{form.watch("providerId") && ( @@ -139,54 +170,7 @@ export const LaunchScanWorkflow = ({ )} -
- - - {form.watch("providerId") && ( - - form.reset()} - className="w-fit border-gray-200 bg-transparent" - ariaLabel="Clear form" - variant="bordered" - size="lg" - radius="lg" - > - Cancel - - } - isDisabled={true} - > - {isLoading ? <>Loading : Schedule} - - - } - > - {isLoading ? <>Loading : Start now} - - - )} - +
*/}
diff --git a/ui/components/scans/launch-workflow/select-scan-provider.tsx b/ui/components/scans/launch-workflow/select-scan-provider.tsx index c1805f488b..781656b59b 100644 --- a/ui/components/scans/launch-workflow/select-scan-provider.tsx +++ b/ui/components/scans/launch-workflow/select-scan-provider.tsx @@ -55,8 +55,8 @@ export const SelectScanProvider = < <>