diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 17a1a740..2b5719c7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,15 +8,17 @@ updates: commit-message: prefix: "ci(github)" schedule: - interval: "weekly" - day: "monday" + interval: "monthly" time: "08:00" + open-pull-requests-limit: 1 + cooldown: + default-days: 7 groups: actions: patterns: - "*" - package-ecosystem: "uv" - target-branch: "main" + # Use the default branch so this configuration also applies to security updates. directory: "/" labels: - "dependencies" @@ -24,44 +26,54 @@ updates: commit-message: prefix: "build(deps)" schedule: - interval: "daily" + interval: "monthly" time: "08:00" - # Keep in sync with group patterns below; this list gates version updates. + # These limits and cooldown affect version updates only; security fixes stay immediate. + open-pull-requests-limit: 2 + cooldown: + default-days: 7 + # Avoid raising minimum requirements when the existing range already permits an update. + versioning-strategy: "increase-if-necessary" + # SemVer filtering here affects version updates only; major security fixes remain eligible. + # Match every package so vulnerable transitive dependencies remain eligible. allow: - - dependency-name: "PyJWT" - - dependency-name: "passlib" - - dependency-name: "bcrypt" - - dependency-name: "ruff" - - dependency-name: "ty" - - dependency-name: "prek" - - dependency-name: "types-*" - - dependency-name: "sqlalchemy-stubs" - - dependency-name: "posthog" - - dependency-name: "sentry-sdk" - - dependency-name: "prometheus-fastapi-instrumentator" + - dependency-name: "*" + update-types: + - "version-update:semver-minor" + - "version-update:semver-patch" groups: security: + applies-to: "security-updates" patterns: - - "PyJWT" - - "passlib" - - "bcrypt" + - "*" quality: + applies-to: "version-updates" patterns: - "ruff" - "ty" - "prek" - "types-*" - "sqlalchemy-stubs" - observability: + python: + applies-to: "version-updates" patterns: - - "posthog" - - "sentry-sdk" - - "prometheus-fastapi-instrumentator" - - package-ecosystem: "docker" + - "*" + # Compose manifests live at the root; Dockerfiles live under src/. + # Update uv in the Dockerfiles together with every workflow's UV_VERSION manually. + - package-ecosystem: "docker-compose" directory: "/" schedule: - interval: "daily" + interval: "monthly" time: "08:00" + open-pull-requests-limit: 1 + cooldown: + default-days: 7 allow: - - dependency-name: "ghcr.io/astral-sh/uv" - dependency-name: "localstack/localstack" + update-types: + - "version-update:semver-minor" + - "version-update:semver-patch" + groups: + localstack: + patterns: + - "localstack/localstack" diff --git a/client/pyproject.toml b/client/pyproject.toml index 69635060..738606d1 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -39,12 +39,11 @@ test = [ "types-requests>=2.33.0.20260712", ] docs = [ - "sphinx>=3.0.0,!=3.5.0", + "sphinx>=5.3.0", "sphinxemoji>=0.1.8", "sphinx-copybutton>=0.3.1", "docutils<0.18", - # cf. https://github.com/readthedocs/readthedocs.org/issues/9038 - "Jinja2<3.1", + "Jinja2>=3.1.6,<4.0.0", "furo>=2022.3.4", ] diff --git a/pyproject.toml b/pyproject.toml index 74b00326..f88d7c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ server = [ "pydantic>=2.0.0,<3.0.0", "pydantic-settings>=2.15.0,<3.0.0", "requests>=2.32.0,<3.0.0", - "PyJWT>=2.12.0,<3.0.0", + "PyJWT>=2.15.0,<3.0.0", "bcrypt==5.0.0", "uvicorn>=0.11.1,<1.0.0", "asyncpg>=0.25.0,<1.0.0", diff --git a/src/app/core/config.py b/src/app/core/config.py index 3812f318..55db65ef 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -9,7 +9,7 @@ import socket from typing import Union -from pydantic import field_validator, model_validator +from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict __all__ = ["settings"] @@ -41,11 +41,18 @@ def sqlachmey_uri(cls, v: str) -> str: return v # Security - JWT_SECRET: str = os.environ.get("JWT_SECRET") or secrets.token_urlsafe(32) + JWT_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) JWT_EXPIRE_MINUTES: int = 60 JWT_UNLIMITED: int = 60 * 24 * 365 JWT_ALGORITHM: str = "HS256" + @field_validator("JWT_SECRET") + @classmethod + def jwt_secret_can_be_blank(cls, v: str) -> str: + # Compose passes an empty value when no secret is configured. Normalize it + # after settings loading so it cannot replace the generated default. + return v or secrets.token_urlsafe(32) + # DB conversion MAX_BOXES_PER_DETECTION: int = 5 DECIMALS_PER_COORD: int = 3 diff --git a/src/tests/test_config.py b/src/tests/test_config.py index 72ae9c63..e6292e75 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -1,4 +1,5 @@ import logging +import secrets import pytest from pydantic import ValidationError @@ -6,6 +7,29 @@ from app.core.config import Settings +@pytest.mark.parametrize("secret", [None, ""]) +def test_settings_generate_jwt_secret_when_unset_or_empty(monkeypatch, secret): + if secret is None: + monkeypatch.delenv("JWT_SECRET", raising=False) + else: + monkeypatch.setenv("JWT_SECRET", secret) + + first = Settings().JWT_SECRET + second = Settings().JWT_SECRET + assert len(first) >= 32 + assert len(second) >= 32 + assert first != second + + +def test_settings_preserve_configured_jwt_secret(monkeypatch): + secret = secrets.token_urlsafe(32) + monkeypatch.setenv("JWT_SECRET", secret) + first = Settings().JWT_SECRET + second = Settings().JWT_SECRET + assert first == secret + assert second == secret + + @pytest.mark.parametrize( "name", ["SEQUENCE_RELAXATION_SECONDS", "SEQUENCE_MIN_INTERVAL_SECONDS", "SEQUENCE_CONTINUITY_SECONDS"], diff --git a/uv.lock b/uv.lock index 04027aa4..8132030f 100644 --- a/uv.lock +++ b/uv.lock @@ -68,15 +68,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -871,14 +871,14 @@ wheels = [ [[package]] name = "jinja2" -version = "3.0.3" +version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/a5/429efc6246119e1e3fbf562c00187d04e83e54619249eb732bb423efa6c6/Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7", size = 269196, upload-time = "2021-11-09T20:27:29.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/9a/e5d9ec41927401e41aea8af6d16e78b5e612bca4699d417f646a9610a076/Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", size = 133630, upload-time = "2021-11-09T20:27:27.116Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] @@ -1602,11 +1602,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [[package]] @@ -1775,7 +1775,7 @@ server = [ { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0,<9.0.0" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.15.0,<3.0.0" }, - { name = "pyjwt", specifier = ">=2.12.0,<3.0.0" }, + { name = "pyjwt", specifier = ">=2.15.0,<3.0.0" }, { name = "pyproj", specifier = ">=3.6.0,<4.0.0" }, { name = "pyro-camera-api-client", git = "https://github.com/pyronear/pyro-engine.git?subdirectory=pyro_camera_api%2Fclient&rev=a4e914e4714a8d2714eb0752233c3889e39fe666" }, { name = "python-magic", specifier = ">=0.4.17,<0.5.0" }, @@ -1833,12 +1833,12 @@ test = [ requires-dist = [ { name = "docutils", marker = "extra == 'docs'", specifier = "<0.18" }, { name = "furo", marker = "extra == 'docs'", specifier = ">=2022.3.4" }, - { name = "jinja2", marker = "extra == 'docs'", specifier = "<3.1" }, + { name = "jinja2", marker = "extra == 'docs'", specifier = ">=3.1.6,<4.0.0" }, { name = "pytest", marker = "extra == 'test'", specifier = "==9.1.1" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=7.1.0,<8.0.0" }, { name = "pytest-pretty", marker = "extra == 'test'", specifier = ">=1.0.0,<2.0.0" }, { name = "requests", specifier = ">=2.33.0,<3.0.0" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = ">=3.0.0,!=3.5.0" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=5.3.0" }, { name = "sphinx-copybutton", marker = "extra == 'docs'", specifier = ">=0.3.1" }, { name = "sphinxemoji", marker = "extra == 'docs'", specifier = ">=0.1.8" }, { name = "types-requests", marker = "extra == 'test'", specifier = ">=2.33.0.20260712" }, @@ -2477,11 +2477,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]]