diff --git a/.env b/.env index 99ec6b55d2..191b1fbf2b 100644 --- a/.env +++ b/.env @@ -14,8 +14,8 @@ NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs UI_API_DOCS_URL=http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST=true UI_PORT=3000 -# openssl rand -base64 32 -AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8=" +# Required before the first start, no default is shipped: openssl rand -base64 32 +AUTH_SECRET="" # Google Tag Manager ID (empty/unset ⇒ GTM not loaded, zero egress) # deprecated, use UI_GOOGLE_TAG_MANAGER_ID NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID="" @@ -155,8 +155,10 @@ DJANGO_MANAGE_DB_PARTITIONS=True DJANGO_TOKEN_SIGNING_KEY="" # openssl rsa -in private.pem -pubout -out public.pem DJANGO_TOKEN_VERIFYING_KEY="" -# openssl rand -base64 32 -DJANGO_SECRETS_ENCRYPTION_KEY="oE/ltOhp/n1TdbHjVmzcjDPLcLA41CVI/4Rk+UB5ESc=" +# Fernet key protecting every stored credential. Leave empty to generate it on the +# first start, persisted in _data/api/secrets_encryption.key: keep that file across +# upgrades. To set it yourself: openssl rand -base64 32 +DJANGO_SECRETS_ENCRYPTION_KEY="" DJANGO_BROKER_VISIBILITY_TIMEOUT=86400 DJANGO_SENTRY_DSN= DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute diff --git a/README.md b/README.md index 2851101f05..b064f0598f 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/la curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml" # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/.env" +# AUTH_SECRET is not shipped: set it in .env before the first start +perl -pi -e "s|^AUTH_SECRET=.*|AUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env docker compose up -d ``` @@ -195,6 +197,9 @@ $VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/p Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml" # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env" +# AUTH_SECRET is not shipped: set it in .env before the first start +$AuthSecret = [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) +(Get-Content .env) -replace '^AUTH_SECRET=.*', "AUTH_SECRET=`"$AuthSecret`"" | Set-Content .env docker compose up -d ``` diff --git a/api/changelog.d/selfhosted-compose-loopback-ports.security.md b/api/changelog.d/selfhosted-compose-loopback-ports.security.md new file mode 100644 index 0000000000..78ee6e8579 --- /dev/null +++ b/api/changelog.d/selfhosted-compose-loopback-ports.security.md @@ -0,0 +1 @@ +PostgreSQL, Valkey and Neo4j ports in the self-hosted `docker-compose.yml` bind to `127.0.0.1` instead of every host interface, and `worker` and `worker-beat` share the API key directory diff --git a/api/changelog.d/selfhosted-shipped-secrets.security.md b/api/changelog.d/selfhosted-shipped-secrets.security.md new file mode 100644 index 0000000000..8594a6ca0b --- /dev/null +++ b/api/changelog.d/selfhosted-shipped-secrets.security.md @@ -0,0 +1 @@ +`DJANGO_SECRETS_ENCRYPTION_KEY` and `SECRET_KEY` are generated on the first start and persisted under `_data/api` when empty, the API refuses to start with an encryption key that was ever published in this repository, and the self-hosted `.env` no longer ships working values for `DJANGO_SECRETS_ENCRYPTION_KEY` and `AUTH_SECRET` diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index 1aa0f8f54f..40cf71866e 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -1,32 +1,65 @@ +import hashlib import logging import os import sys +import time from pathlib import Path from config.custom_logging import BackendLogger from config.env import env from django.apps import AppConfig from django.conf import settings +from django.core.exceptions import ImproperlyConfigured logger = logging.getLogger(BackendLogger.API) SIGNING_KEY_ENV = "DJANGO_TOKEN_SIGNING_KEY" VERIFYING_KEY_ENV = "DJANGO_TOKEN_VERIFYING_KEY" +ENCRYPTION_KEY_ENV = "DJANGO_SECRETS_ENCRYPTION_KEY" +SECRET_KEY_ENV = "SECRET_KEY" PRIVATE_KEY_FILE = "jwt_private.pem" PUBLIC_KEY_FILE = "jwt_public.pem" +ENCRYPTION_KEY_FILE = "secrets_encryption.key" +SECRET_KEY_FILE = "django_secret.key" KEYS_DIRECTORY = ( Path.home() / ".config" / "prowler-api" ) # `/home/prowler/.config/prowler-api` inside the container +# SHA-256 of encryption keys that were committed to this repository with a working +# value. They are public, so a deployment still using one of them must rotate. +PUBLISHED_ENCRYPTION_KEY_DIGESTS = frozenset( + { + # Root `.env` shipped to self-hosted installs until this check was added + "a9c1d15afaddc1753245e48117f7c80525973a4f64179d447a843ca372c0ba0e", + # contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml + "c7576d54c7120bab3a486d44a7a8640907cc28eaeec6bc41f4aa2e95ad407015", + } +) + +ENCRYPTION_KEY_DOCS = ( + "https://docs.prowler.com/getting-started/installation/prowler-app" + "#secrets-encryption-key" +) + _keys_initialized = False # Flag to prevent multiple executions within the same process +def _digest(value): + return hashlib.sha256(value.encode()).hexdigest() + + class ApiConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "api" + def import_models(self): + # `api.models` builds its Fernet instance at import time, so the encryption + # key has to be settled before Django loads this app's models. + self._ensure_secrets() + super().import_models() + def ready(self): from api import ( schema_extensions, # noqa: F401 @@ -42,11 +75,119 @@ class ApiConfig(AppConfig): ): self._ensure_crypto_keys() + def _ensure_secrets(self): + """ + Settle the symmetric secrets every process needs before the models load: + - `DJANGO_SECRETS_ENCRYPTION_KEY`, the Fernet key protecting stored credentials + - `SECRET_KEY`, Django's signing key + Both are generated and persisted under `KEYS_DIRECTORY` when empty. An + encryption key that was ever published with this repository is refused. + """ + if getattr(settings, "TESTING", False): + return + + self._ensure_encryption_key() + self._ensure_secret_key() + + def _ensure_encryption_key(self): + key = (getattr(settings, "SECRETS_ENCRYPTION_KEY", "") or "").strip() + + if key: + if _digest(key) in PUBLISHED_ENCRYPTION_KEY_DIGESTS: + raise ImproperlyConfigured( + f"'{ENCRYPTION_KEY_ENV}' is set to a value that was published in the " + "Prowler repository and is therefore public. Generate a new key, " + f"re-enter the stored credentials and restart. See {ENCRYPTION_KEY_DOCS}" + ) + return + + key = self._load_or_generate_secret( + ENCRYPTION_KEY_FILE, ENCRYPTION_KEY_ENV, self._generate_encryption_key + ) + + os.environ[ENCRYPTION_KEY_ENV] = key + settings.SECRETS_ENCRYPTION_KEY = key + # Mutated in place: `drf_simple_apikey` keeps a reference to this dict + settings.DRF_API_KEY["FERNET_SECRET"] = key + + def _ensure_secret_key(self): + # Read from the environment: `settings.SECRET_KEY` raises while it is empty + if env.str(SECRET_KEY_ENV, default="").strip(): + return + + key = self._load_or_generate_secret( + SECRET_KEY_FILE, SECRET_KEY_ENV, self._generate_secret_key + ) + + os.environ[SECRET_KEY_ENV] = key + settings.SECRET_KEY = key + + def _load_or_generate_secret(self, file_name, env_name, generate): + """ + Return the secret persisted as `file_name`, generating it on first boot. + """ + existing = self._read_key_file(file_name) + if existing: + return existing + + file_path = KEYS_DIRECTORY / file_name + value = generate() + + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + # Another process of this deployment generated it first + return self._wait_for_key_file(file_name, env_name) + except OSError as e: + logger.error(f"Cannot persist '{file_name}' under '{KEYS_DIRECTORY}': {e}") + raise ImproperlyConfigured( + f"'{env_name}' is empty and no value could be persisted under " + f"'{KEYS_DIRECTORY}'. Set '{env_name}' in the environment or make the " + "directory writable." + ) from e + + with os.fdopen(fd, "w") as key_file: + key_file.write(value) + + logger.warning( + f"'{env_name}' was empty: generated '{file_name}' under '{KEYS_DIRECTORY}'. " + "Keep that file across upgrades and back it up; losing it makes the data " + "it protects unreadable." + ) + return value + + def _wait_for_key_file(self, file_name, env_name): + for _ in range(10): + existing = self._read_key_file(file_name) + if existing: + return existing + time.sleep(0.1) + + raise ImproperlyConfigured( + f"'{env_name}' is empty and '{file_name}' under '{KEYS_DIRECTORY}' could " + "not be read. Set the variable in the environment or remove the file." + ) + + @staticmethod + def _generate_encryption_key(): + from cryptography.fernet import Fernet + + return Fernet.generate_key().decode() + + @staticmethod + def _generate_secret_key(): + from django.core.management.utils import get_random_secret_key + + return get_random_secret_key() + def _ensure_crypto_keys(self): """ Orchestrator method that ensures all required cryptographic keys are present. This method coordinates the generation of: - RSA key pairs for JWT token signing and verification + The symmetric secrets are handled earlier, in `_ensure_secrets`, because the + models need them at import time. Note: During development, Django spawns multiple processes (migrations, fixtures, etc.) which will each generate their own keys. This is expected behavior and each process will have consistent keys for its lifetime. In production, set the keys as environment diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 93934df674..667ce52cef 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -8,13 +8,20 @@ import api import api.apps as api_apps_module import pytest from api.apps import ( + ENCRYPTION_KEY_ENV, + ENCRYPTION_KEY_FILE, PRIVATE_KEY_FILE, PUBLIC_KEY_FILE, + SECRET_KEY_ENV, + SECRET_KEY_FILE, SIGNING_KEY_ENV, VERIFYING_KEY_ENV, ApiConfig, ) +from cryptography.fernet import Fernet +from django.apps import AppConfig from django.conf import settings +from django.core.exceptions import ImproperlyConfigured @pytest.fixture(autouse=True) @@ -203,3 +210,163 @@ def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv): config.ready() init_driver.assert_not_called() + + +@pytest.fixture +def secrets_dir(monkeypatch, tmp_path): + """Isolated key directory with both symmetric secrets empty and generation enabled.""" + monkeypatch.setattr( + api_apps_module, "KEYS_DIRECTORY", Path(tmp_path), raising=False + ) + monkeypatch.setattr(settings, "TESTING", False, raising=False) + monkeypatch.setattr(settings, "SECRET_KEY", settings.SECRET_KEY, raising=False) + monkeypatch.setattr(settings, "SECRETS_ENCRYPTION_KEY", "", raising=False) + monkeypatch.setattr( + settings, "DRF_API_KEY", settings.DRF_API_KEY.copy(), raising=False + ) + monkeypatch.delenv(ENCRYPTION_KEY_ENV, raising=False) + monkeypatch.delenv(SECRET_KEY_ENV, raising=False) + return Path(tmp_path) + + +def _throwaway_fernet_key(): + return Fernet.generate_key().decode() + + +def test_ensure_secrets_refuses_published_encryption_key(monkeypatch, secrets_dir): + published = _throwaway_fernet_key() + monkeypatch.setattr(settings, "SECRETS_ENCRYPTION_KEY", published, raising=False) + monkeypatch.setattr( + api_apps_module, + "PUBLISHED_ENCRYPTION_KEY_DIGESTS", + frozenset({api_apps_module._digest(published)}), + raising=False, + ) + + with pytest.raises(ImproperlyConfigured) as exc_info: + _make_app()._ensure_secrets() + + assert ENCRYPTION_KEY_ENV in str(exc_info.value) + assert published not in str(exc_info.value) + assert not (secrets_dir / ENCRYPTION_KEY_FILE).exists() + + +def test_ensure_secrets_generates_encryption_key_when_empty(secrets_dir): + _make_app()._ensure_secrets() + + key_path = secrets_dir / ENCRYPTION_KEY_FILE + generated = key_path.read_text() + assert key_path.stat().st_mode & 0o777 == 0o600 + Fernet(generated.encode()) + assert settings.SECRETS_ENCRYPTION_KEY == generated + assert settings.DRF_API_KEY["FERNET_SECRET"] == generated + assert os.environ[ENCRYPTION_KEY_ENV] == generated + + +def test_ensure_secrets_reuses_persisted_encryption_key(monkeypatch, secrets_dir): + persisted = _throwaway_fernet_key() + (secrets_dir / ENCRYPTION_KEY_FILE).write_text(persisted + "\n") + + def _fail_generate(): + raise AssertionError("a persisted encryption key must not be regenerated") + + monkeypatch.setattr( + ApiConfig, "_generate_encryption_key", staticmethod(_fail_generate) + ) + + _make_app()._ensure_secrets() + + assert settings.SECRETS_ENCRYPTION_KEY == persisted + assert (secrets_dir / ENCRYPTION_KEY_FILE).read_text() == persisted + "\n" + + +def test_ensure_secrets_keeps_explicit_encryption_key(monkeypatch, secrets_dir): + explicit = _throwaway_fernet_key() + monkeypatch.setattr(settings, "SECRETS_ENCRYPTION_KEY", explicit, raising=False) + + _make_app()._ensure_secrets() + + assert settings.SECRETS_ENCRYPTION_KEY == explicit + assert not (secrets_dir / ENCRYPTION_KEY_FILE).exists() + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions") +def test_ensure_secrets_refuses_empty_encryption_key_when_unpersistable( + monkeypatch, secrets_dir +): + secrets_dir.chmod(0o500) + monkeypatch.setattr( + api_apps_module, "KEYS_DIRECTORY", secrets_dir / "keys", raising=False + ) + + try: + with pytest.raises(ImproperlyConfigured) as exc_info: + _make_app()._ensure_secrets() + finally: + secrets_dir.chmod(0o700) + + assert ENCRYPTION_KEY_ENV in str(exc_info.value) + assert settings.SECRETS_ENCRYPTION_KEY == "" + + +def test_ensure_secrets_recovers_when_another_process_wrote_the_key_first( + monkeypatch, secrets_dir +): + winner = _throwaway_fernet_key() + real_open = os.open + + def _lose_the_race(path, flags, *args): + if Path(path).name == ENCRYPTION_KEY_FILE: + (secrets_dir / ENCRYPTION_KEY_FILE).write_text(winner) + return real_open(path, flags, *args) + + monkeypatch.setattr(api_apps_module.os, "open", _lose_the_race) + + _make_app()._ensure_secrets() + + assert settings.SECRETS_ENCRYPTION_KEY == winner + + +def test_ensure_secrets_generates_secret_key_when_empty(monkeypatch, secrets_dir): + monkeypatch.setattr( + settings, "SECRETS_ENCRYPTION_KEY", _throwaway_fernet_key(), raising=False + ) + + _make_app()._ensure_secrets() + + generated = (secrets_dir / SECRET_KEY_FILE).read_text() + assert len(generated) == 50 + assert settings.SECRET_KEY == generated + assert os.environ[SECRET_KEY_ENV] == generated + + +def test_ensure_secrets_keeps_explicit_secret_key(monkeypatch, secrets_dir): + monkeypatch.setattr( + settings, "SECRETS_ENCRYPTION_KEY", _throwaway_fernet_key(), raising=False + ) + monkeypatch.setenv(SECRET_KEY_ENV, "explicit-throwaway-secret-key") + + _make_app()._ensure_secrets() + + assert not (secrets_dir / SECRET_KEY_FILE).exists() + + +def test_ensure_secrets_is_skipped_while_testing(monkeypatch, secrets_dir): + monkeypatch.setattr(settings, "TESTING", True, raising=False) + + _make_app()._ensure_secrets() + + assert not (secrets_dir / ENCRYPTION_KEY_FILE).exists() + assert not (secrets_dir / SECRET_KEY_FILE).exists() + + +def test_import_models_settles_secrets_before_loading_models(monkeypatch): + calls = [] + monkeypatch.setattr( + ApiConfig, "_ensure_secrets", lambda self: calls.append("secrets") + ) + monkeypatch.setattr(AppConfig, "import_models", lambda self: calls.append("models")) + + _make_app().import_models() + + assert calls == ["secrets", "models"] diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 664fcd1c4a..9b087a0a45 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -9,7 +9,7 @@ from config.settings.sentry import * # noqa from config.settings.social_login import * # noqa from django.core.exceptions import ImproperlyConfigured -SECRET_KEY = env("SECRET_KEY", default="secret") +SECRET_KEY = env("SECRET_KEY", default="") DEBUG = env.bool("DJANGO_DEBUG", default=False) ALLOWED_HOSTS = ["localhost", "127.0.0.1"] SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/api/src/backend/config/django/testing.py b/api/src/backend/config/django/testing.py index 9951478bfd..524e166aca 100644 --- a/api/src/backend/config/django/testing.py +++ b/api/src/backend/config/django/testing.py @@ -18,6 +18,8 @@ DATABASES = { DATABASE_ROUTERS = [] TESTING = True +# `_ensure_secrets` is skipped while testing, and Django rejects an empty SECRET_KEY +SECRET_KEY = env("SECRET_KEY", default="insecure-testing-secret-key-do-not-use-in-prod") # Override page size for testing to a value only slightly above the current fixture count. # We explicitly set PAGE_SIZE to 15 (round number just above fixture) to avoid masking pagination bugs, while not setting it excessively high. # If you add more providers to the fixture, please review that the total value is below the current one and update this value if needed. diff --git a/docker-compose.yml b/docker-compose.yml index 4b836aba6c..4140c7cd65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,9 @@ services: ui: image: prowlercloud/prowler-ui:${PROWLER_UI_VERSION:-stable} restart: unless-stopped + environment: + # No default is shipped: abort `docker compose up` instead of crash-looping the UI + - AUTH_SECRET=${AUTH_SECRET:?generate it with openssl rand -base64 32 and set it in .env} env_file: - path: .env required: false @@ -75,7 +78,7 @@ services: - path: .env required: false ports: - - "${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}" + - "127.0.0.1:${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}" healthcheck: test: ["CMD-SHELL", "sh -c 'pg_isready -U ${POSTGRES_ADMIN_USER} -d ${POSTGRES_DB}'"] interval: 5s @@ -92,7 +95,7 @@ services: - path: .env required: false ports: - - "${VALKEY_PORT:-6379}:6379" + - "127.0.0.1:${VALKEY_PORT:-6379}:6379" healthcheck: test: ["CMD-SHELL", "sh -c 'valkey-cli ping'"] interval: 10s @@ -125,7 +128,7 @@ services: # Networking - "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}" ports: - - ${NEO4J_PORT:-7687}:7687 + - "127.0.0.1:${NEO4J_PORT:-7687}:7687" healthcheck: test: ["CMD", "wget", "--no-verbose", "http://localhost:7474"] interval: 10s @@ -141,6 +144,7 @@ services: - path: .env required: false volumes: + - ./_data/api:/home/prowler/.config/prowler-api - "output:/tmp/prowler_api_output" depends_on: api: @@ -159,6 +163,8 @@ services: env_file: - path: ./.env required: false + volumes: + - ./_data/api:/home/prowler/.config/prowler-api depends_on: api: condition: service_healthy diff --git a/docs/dockerhub/README.md b/docs/dockerhub/README.md index 49c3983473..371fce274e 100644 --- a/docs/dockerhub/README.md +++ b/docs/dockerhub/README.md @@ -55,6 +55,8 @@ The Prowler CLI image is also available on AWS Public ECR: [`public.ecr.aws/prow ```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 +# AUTH_SECRET is not shipped: set it in .env before the first start +perl -pi -e "s|^AUTH_SECRET=.*|AUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env docker compose up -d ``` diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index c768d078c4..f8375fa6e7 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -26,6 +26,8 @@ Refer to the [Prowler Cloud guide](/user-guide/tutorials/prowler-app) for detail curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml" # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/.env" + # AUTH_SECRET is not shipped: set it in .env before the first start + perl -pi -e "s|^AUTH_SECRET=.*|AUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env docker compose up -d ``` @@ -34,12 +36,17 @@ Refer to the [Prowler Cloud guide](/user-guide/tutorials/prowler-app) for detail Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml" # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env" + # AUTH_SECRET is not shipped: set it in .env before the first start + $AuthSecret = [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) + (Get-Content .env) -replace '^AUTH_SECRET=.*', "AUTH_SECRET=`"$AuthSecret`"" | Set-Content .env docker compose up -d ``` For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API. + + The API also generates `DJANGO_SECRETS_ENCRYPTION_KEY` on the first start, stored next to the key pair as `secrets_encryption.key`. Unlike the key pair, this one cannot be rotated by deleting the file: it protects every stored credential. See [Secrets encryption key](#secrets-encryption-key). @@ -148,9 +155,19 @@ The `--policy always` flag ensures that Docker pulls the latest images even if t **What Gets Preserved During Upgrade** - Everything is preserved, nothing will be deleted after the update. + Everything is preserved, nothing will be deleted after the update. The `_data/` directory holds the database, the graph store and the keys under `_data/api`, including `secrets_encryption.key`: never remove it when upgrading. +### Secrets encryption key + +`DJANGO_SECRETS_ENCRYPTION_KEY` is the symmetric key that encrypts every cloud provider credential, integration credential and LLM key stored by Prowler. When the variable is empty, the API generates the key on the first start and persists it as `_data/api/secrets_encryption.key` (`~/.config/prowler-api/secrets_encryption.key` outside a container), where the `api`, `worker` and `worker-beat` containers read it. To manage it yourself, set the variable to the output of `openssl rand -base64 32` before the first start. + + + Keep this key across upgrades and back it up together with the database. Rotating it means re-encrypting every stored credential: Prowler does not do that automatically, so setting a new key leaves the existing credentials unreadable and they have to be entered again. + + +Older `.env` files shipped a working value for this variable, so every installation that kept it shares the same public key. The API now refuses to start with it and logs `DJANGO_SECRETS_ENCRYPTION_KEY is set to a value that was published in the Prowler repository`. To recover, clear the variable in `.env` (or set a freshly generated one), restart the stack and enter the provider, integration and LLM credentials again; they were encrypted with a public key and must be considered exposed. + ### Troubleshooting Installation Issues If containers don't start, check logs for errors: