diff --git a/api/changelog.d/selfhosted-published-signing-key.security.md b/api/changelog.d/selfhosted-published-signing-key.security.md new file mode 100644 index 0000000000..623c6f7871 --- /dev/null +++ b/api/changelog.d/selfhosted-published-signing-key.security.md @@ -0,0 +1 @@ +API refuses to start with a JWT signing key published in this repository, and the Helm minimal-installation example no longer ships working secrets diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index 40cf71866e..87d1d2148b 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -38,6 +38,15 @@ PUBLISHED_ENCRYPTION_KEY_DIGESTS = frozenset( } ) +# SHA-256 of the base64 body of JWT signing keys that were committed to this +# repository with a working value. Anyone holding one can forge tokens. +PUBLISHED_SIGNING_KEY_DIGESTS = frozenset( + { + # contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml + "375d5755ab4a7d38c58b38c67c1a6d1cab5afc91844445d8489c15ce7779d949", + } +) + ENCRYPTION_KEY_DOCS = ( "https://docs.prowler.com/getting-started/installation/prowler-app" "#secrets-encryption-key" @@ -50,6 +59,16 @@ def _digest(value): return hashlib.sha256(value.encode()).hexdigest() +def _pem_digest(value): + """Digest of a PEM's base64 body, so indentation and line wrapping do not matter.""" + body = "".join( + line.strip() + for line in value.splitlines() + if line.strip() and "-----" not in line + ) + return _digest(body) + + class ApiConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "api" @@ -207,6 +226,16 @@ class ApiConfig(AppConfig): signing_key = env.str(SIGNING_KEY_ENV, default="").strip() verifying_key = env.str(VERIFYING_KEY_ENV, default="").strip() + if signing_key and _pem_digest(signing_key) in PUBLISHED_SIGNING_KEY_DIGESTS: + raise ImproperlyConfigured( + f"'{SIGNING_KEY_ENV}' is set to a key pair that was published in the " + "Prowler repository and is therefore public. Anyone holding it can " + "forge API tokens. Generate a new pair with 'openssl genrsa -out " + "private.pem 2048' and 'openssl rsa -in private.pem -pubout', set both " + "variables and restart. Existing sessions end and users sign in again; " + "no stored data needs re-encrypting." + ) + if not signing_key or not verifying_key: logger.info( f"Generating JWT RSA key pair. In production, set '{SIGNING_KEY_ENV}' and '{VERIFYING_KEY_ENV}' " diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 667ce52cef..fe61873fe4 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -251,6 +251,40 @@ def test_ensure_secrets_refuses_published_encryption_key(monkeypatch, secrets_di assert not (secrets_dir / ENCRYPTION_KEY_FILE).exists() +def test_ensure_crypto_keys_refuses_published_signing_key(monkeypatch, tmp_path): + published, verifying = _stub_keys() + monkeypatch.setattr( + api_apps_module, "KEYS_DIRECTORY", Path(tmp_path), raising=False + ) + monkeypatch.setattr(api_apps_module, "_keys_initialized", False, raising=False) + monkeypatch.setenv(SIGNING_KEY_ENV, published) + monkeypatch.setenv(VERIFYING_KEY_ENV, verifying) + monkeypatch.setattr(settings, "TESTING", False, raising=False) + monkeypatch.setattr( + api_apps_module, + "PUBLISHED_SIGNING_KEY_DIGESTS", + frozenset({api_apps_module._pem_digest(published)}), + raising=False, + ) + + with pytest.raises(ImproperlyConfigured) as exc_info: + ApiConfig("api", api_apps_module)._ensure_crypto_keys() + + assert SIGNING_KEY_ENV in str(exc_info.value) + assert "PRIVATE" not in str(exc_info.value) + assert not (Path(tmp_path) / PRIVATE_KEY_FILE).exists() + + +def test_pem_digest_ignores_indentation_and_wrapping(): + # derived from the existing stub so no PEM literal is added to this file + flat = _stub_keys()[0] + indented = "".join(f" {line}\n" for line in flat.splitlines()) + wrapped = flat.replace("PRIVATE\n", "PRIV\nATE\n", 1) + + assert api_apps_module._pem_digest(indented) == api_apps_module._pem_digest(flat) + assert api_apps_module._pem_digest(wrapped) == api_apps_module._pem_digest(flat) + + def test_ensure_secrets_generates_encryption_key_when_empty(secrets_dir): _make_app()._ensure_secrets() diff --git a/contrib/k8s/helm/prowler-app/README.md b/contrib/k8s/helm/prowler-app/README.md index 544b5f39a7..835c26137c 100644 --- a/contrib/k8s/helm/prowler-app/README.md +++ b/contrib/k8s/helm/prowler-app/README.md @@ -86,7 +86,7 @@ Before installing the Helm chart, you must create a Kubernetes Secret containing NEO4J_AUTH: "neo4j/[prowler-password]" ``` - > **Note:** You can use the [example secrets file](./examples/minimal-installation/secrets.yaml) as a template, but **always replace the placeholder values with your own secure keys** before applying. + > **Note:** You can use the [example secrets file](./examples/minimal-installation/secrets.yaml) as a template, but **always replace the placeholder values with your own secure keys** before applying. Earlier revisions of that example shipped working values; the API refuses to start with any key that was published in this repository, so a deployment still using one must generate a new one. 3. **Apply the secret to your cluster:** diff --git a/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml b/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml index 2e379ef5c8..254cf85d69 100644 --- a/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml +++ b/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml @@ -4,54 +4,28 @@ type: Opaque metadata: name: prowler-secret stringData: + # Every value below must be generated for your own installation. The values that + # used to ship here were public, and the API refuses to start with them. + # openssl genrsa -out private.pem 2048 DJANGO_TOKEN_SIGNING_KEY: | -----BEGIN PRIVATE KEY----- - MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCIro0QiLAxw7rF - GO0NgAWJfkpYE5ysMGDCbId07HUrv+/SCoRjqKVzGJVIvmNP5oByzSehPgswW9v3 - 3dqe2r9sCS1JyMa+XO3qfZCR0uRDcPCwZjIyr0QQLpWAymdBa8baeHsU1/3Orjcb - Vrr+lNx4HQJOiSn094iXPReW/25hYeq/SXs79V2CR87PGdoZAhb8IllAxJgdfkeB - /iWohY/1vfRTmIuMweWGXk0aKzPsBdvE/DqG4HjiNVEPh18G3vid0YTZNmm7u8vO - Cue3x9NQWGHA4QtxNtLtxlHcOEryqZ9ChO2nC+ew0Xl/v706XFNyLFicjisIKNQo - qdkaMS33AgMBAAECggEAGdJIChCYoL4mYafk2MEPyrrWFq+V0J3PGcvhB0DInfxD - tT2RZzZsE0NYqIZ3Qpf8OjPxwa9z863W74u1Cn+u3B0bti29BieONteD4VijEO6c - OecEorijth7m1Y7nVN+kkI9kSTrI0yvsczi+WOwMfpCUZ/vXtlSxNEkxVLBqzPCo - 9VxAFIjgWOj2rpw8nxPedves36PUrC5ghLqrOTe1jmw/Di0++47AXG+DsTXc00sc - 5+oybopm3Kimsxrqbf9s8SZf2A8NiwqcbLj8OtP2j2g4TCEgZYLD5Zmt+JN/wN4B - WsQG/Hwp4KPPm9QTHEpuuoPFP1CZWZeq8gPcV4apYQKBgQC+TuXjJCYhZqNIttTZ - z/i3hkKUEKQLkzTZnXaDzL5wHyEMVqM2E/WkilO0C9ZZwh0ENPzkp+JsHf7LEhHy - wSHOti81VzUCjN/YpCBKlOlClqSiDlOonImrobLei8xgvmA0VmGtirCXZyyzZUoV - OyPr17WpK6G/M5piX59MvKQg0QKBgQC33NBoQFD8A6FjrTopYmWfK099k9uQh9NE - bvUYsNAPunSDslmc/0PPHQC7fRX5Ime2BinXAN1PYtB/Fsu3jv/+FCUM5hVil0Dd - KBvt13+RYSCJKlhcGP1EkWoIg1F2XXBOZKJrC8VQ+Vyl2t06UcWQqy5M9J4VZaqI - fruOLU/URwKBgE55GjJfZZnASPRi78IhD94dbra/ZeWf/dr+IzCV7LEvJOGBmCtk - b5Y5s+o6N1krwetKLj3bPHJ4q+fwu5XuLZKfbTgBjcpPbL5YbzhRzx22IIzye2y7 - n8k2FBvQaaY62lC6jeyRk9/am4Qd8D5w9I77k9z+MOQ20yJda8KoxsUBAoGBAIQ9 - 5QPmppjsf4ry0C9t30uhWhYnX7fPiYviBpVQrwVxBVan076Q9xOjd6BicohzT4bj - XfqPW546o12VZsbKqqLzmEZzwpPb2EJ5E8V4xv8ojb86Xr03GArWUB55XQE2aY1o - 4kz99VitUg7UoWPN5ryL8sxU8NLRAdwU0w+K1a0HAoGAZaU7O94u9IIPZ6Ohobs2 - Vjf/eV0brCKgX61b4z/YhuJdZsyTujhBZUihZwqR696kiFKuzmHx1ghE2ITvnPVN - q0iHxRZzBCnRQ+mQlS0trzphaCP0NVy3osFeAD9mJfnOnSmkU0ua4F81mkvke1eN - 6nnaoAdy2lmMr96/Tye2ty4= + REPLACE WITH THE CONTENTS OF private.pem -----END PRIVATE KEY----- # openssl rsa -in private.pem -pubout -out public.pem DJANGO_TOKEN_VERIFYING_KEY: | -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiK6NEIiwMcO6xRjtDYAF - iX5KWBOcrDBgwmyHdOx1K7/v0gqEY6ilcxiVSL5jT+aAcs0noT4LMFvb993antq/ - bAktScjGvlzt6n2QkdLkQ3DwsGYyMq9EEC6VgMpnQWvG2nh7FNf9zq43G1a6/pTc - eB0CTokp9PeIlz0Xlv9uYWHqv0l7O/VdgkfOzxnaGQIW/CJZQMSYHX5Hgf4lqIWP - 9b30U5iLjMHlhl5NGisz7AXbxPw6huB44jVRD4dfBt74ndGE2TZpu7vLzgrnt8fT - UFhhwOELcTbS7cZR3DhK8qmfQoTtpwvnsNF5f7+9OlxTcixYnI4rCCjUKKnZGjEt - 9wIDAQAB + REPLACE WITH THE CONTENTS OF public.pem -----END PUBLIC KEY----- # openssl rand -base64 32 - DJANGO_SECRETS_ENCRYPTION_KEY: "qYAIWnRK52aBT5YQkBoMEw08j7j3+QIPZXS6+A8Su44=" + # Protects stored provider, integration and LLM credentials. Changing it later + # leaves the existing ones unreadable, so set it before the first scan. + DJANGO_SECRETS_ENCRYPTION_KEY: "" # openssl rand -base64 32 - AUTH_SECRET: "CM9w3Nco2P1RdHaYmD+fmy2nJmSofusdHd4g7Z4KDG4=" + AUTH_SECRET: "" # Unfortunatelly, we need to duplicate the password in two different keys because the Neo4j Helm Chart expects the password in the NEO4J_AUTH key and the application expects it in the NEO4J_PASSWORD key. NEO4J_PASSWORD: "prowler-password-fake"