From d5625f377f9a34ec67c6a9975a21e3128bae7ae7 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 12:04:16 -0500 Subject: [PATCH 1/5] security(compose): stop publishing MySQL/Redis in release configs The release compose files -- docker-compose.release.yml is the one electron-builder bundles into every packaged installer -- published both datastores to the host with no interface binding: mysql: ports: ["3306:3306"] -> 0.0.0.0:3306 redis: ports: ["6380:6379"] -> 0.0.0.0:6380 A bare "3306:3306" binds 0.0.0.0, not loopback, so on any host whose firewall didn't independently block it, both were reachable from the network. MySQL's root password simultaneously defaulted to the literal "omnibioai", committed in this repo -- so the reachable database had a publicly known credential -- and Redis has no auth at all. This is an infrastructure-level bypass of every application-layer control the platform has. IAM authentication, RBAC, organization isolation, audit logging, and the gateway's zero-trust middleware all operate above the datastore. A client speaking the MySQL wire protocol to :3306 presents no JWT, is subject to no organization_id scoping, is filtered by no queryset, and generates no audit event -- it reads every tenant's rows regardless of how thoroughly the application enforces isolation above it. DEPLOYMENT.md's own port table already documented the intent ("3306 | MySQL | Internal only -- not exposed externally in prod"). The compose file just never implemented it. Both datastores stay fully reachable inside the compose network by service name (mysql:3306, redis:6379), which is how every consumer already addressed them -- no connection string changes. api-gateway stays published: it is the intended entry point. Local access is preserved through an explicit opt-in overlay rather than by weakening the default: docker compose -f docker-compose.release.yml \ -f docker-compose.release.dev-ports.yml up -d The overlay binds 127.0.0.1 by default (not 0.0.0.0), is not bundled by electron-builder, and is referenced by neither production startup path, so it cannot become the default by accident -- it takes a second -f. docker-compose.yml (the local dev stack, loaded only when the Electron app is unpackaged) deliberately still publishes ports. That is its purpose; this finding is about the release configuration. Co-Authored-By: Claude Sonnet 5 --- docker-compose-release.yml | 82 ++++++++++++++++--------- docker-compose.release.dev-ports.yml | 31 ++++++++++ docker-compose.release.yml | 92 +++++++++++++++++++--------- 3 files changed, 147 insertions(+), 58 deletions(-) create mode 100644 docker-compose.release.dev-ports.yml diff --git a/docker-compose-release.yml b/docker-compose-release.yml index dd46715..d52235b 100644 --- a/docker-compose-release.yml +++ b/docker-compose-release.yml @@ -2,10 +2,25 @@ # Uses pre-built images from ghcr.io # Version: v0.7.0 # +# NOTE: this file is not the one electron-builder/electron actually bundle +# into packaged installers -- that's docker-compose.release.yml (dot). This +# dash-named file is kept in credential/exposure parity with it (see +# tests/test_compose_release_config.py) for anyone still invoking it +# directly/via the disabled release workflow, but it is not the live +# shipped artifact -- see SECURITY-COMPOSE-HARDENING.md. +# # Setup: # cp .env.example .env -# # Edit .env with your paths and secrets -# docker compose -f docker-compose-release.yml up -d +# # Edit .env with your paths and secrets -- MYSQL_ROOT_PASSWORD, AUTH_SECRET_KEY, +# # LICENSE_SECRET, GF_ADMIN_PASSWORD, LIMSX_DJANGO_SECRET_KEY, JUPYTER_TOKEN, +# # RSTUDIO_PASSWORD, and VSCODE_PASSWORD are REQUIRED -- compose refuses to +# # start without them. +# docker compose -f docker-compose.release.yml up -d +# +# This is the production/default configuration: mysql and redis are NOT +# published to the host. For local development access to them, layer the +# explicit dev overlay: +# docker compose -f docker-compose.release.yml -f docker-compose.release.dev-ports.yml up -d services: @@ -13,13 +28,15 @@ services: # Infrastructure # --------------------------------------------------------------------------- + # Security boundary (see SECURITY-COMPOSE-HARDENING.md): mysql/redis are + # intentionally NOT published to the host in this release/default + # configuration -- every consumer already reaches them over the internal + # Docker network by service name (mysql:3306, redis:6379). mysql: image: mysql:8.0 environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set -- see DEPLOYMENT.md. Refusing to start with a silently-guessable default} MYSQL_DATABASE: ${MYSQL_DEFAULT_DB:-omnibioai} - ports: - - "3306:3306" volumes: - mysql_data:/var/lib/mysql - ${DB_INIT_DIR}:/docker-entrypoint-initdb.d:ro @@ -32,8 +49,6 @@ services: redis: image: redis:7-alpine - ports: - - "6380:6379" volumes: - redis_data:/data healthcheck: @@ -72,8 +87,8 @@ services: DB_PORT: "3306" DB_NAME: omnibioai DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} - SECRET_KEY: ${AUTH_SECRET_KEY:-change-me-in-production} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + SECRET_KEY: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set} ACCESS_TOKEN_EXPIRE_MINUTES: "15" REFRESH_TOKEN_EXPIRE_DAYS: "7" REDIS_URL: redis://redis:6379 @@ -114,7 +129,7 @@ services: DB_HOST: mysql DB_PORT: "3306" DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} DB_NAME: ${MYSQL_DEFAULT_DB:-omnibioai} depends_on: mysql: @@ -134,7 +149,7 @@ services: # api/deps.py::require_platform_admin and # audit/identity.py::validate_identity_token both fell back to the # public literal "change-me" instead of the real secret. - JWT_SECRET: ${AUTH_SECRET_KEY:-change-me-in-production} + JWT_SECRET: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set} depends_on: redis: condition: service_healthy @@ -151,7 +166,7 @@ services: POLICY_URL: http://policy-engine:8002 HPC_URL: http://hpc-policy-engine:8003 AUDIT_URL: http://security-audit:8004 - JWT_SECRET: ${AUTH_SECRET_KEY:-change-me-in-production} + JWT_SECRET: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set} TES_URL: http://tes:8081 WORKBENCH_URL: http://workbench:8000 TOOLSERVER_URL: http://toolserver:9090 @@ -253,7 +268,7 @@ services: DB_PORT: "3306" DB_NAME: omnibioai DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} REDIS_HOST: redis REDIS_PORT: "6380" CELERY_BROKER_URL: redis://redis:6379/1 @@ -273,7 +288,7 @@ services: OMNIBIOAI_MYSQL_HOST: mysql OMNIBIOAI_MYSQL_PORT: "3306" OMNIBIOAI_MYSQL_USER: root - OMNIBIOAI_MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + OMNIBIOAI_MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} OMNIBIOAI_MYSQL_DB: omnibioai GATEWAY_URL: http://api-gateway:8080 RAGBIO_API_KEY: ${RAGBIO_API_KEY:-} @@ -318,11 +333,18 @@ services: DB_PORT: "3306" DB_NAME: omnibioai DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} REDIS_HOST: redis REDIS_PORT: "6380" CELERY_BROKER_URL: redis://redis:6379/1 TES_BASE_URL: http://tes:8081 + # #196: fixed call sites read GATEWAY_URL exclusively -- kept in + # parity with docker-compose.release.yml's own copy of this fix. + # TES_BASE_URL above stays pointed directly at TES (not repointed to + # the gateway) -- other readers of it in this same image have no + # bearer token attached and would silently 401 if this var suddenly + # routed through gateway's AuthMiddleware. + GATEWAY_URL: http://api-gateway:8080 TOOLSERVER_BASE_URL: http://toolserver:9090 MODEL_REGISTRY_BASE_URL: http://model-registry:8095 RAG_BASE_URL: http://rag:8096 @@ -362,7 +384,7 @@ services: DB_HOST: mysql DB_PORT: "3306" DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} DB_NAME: model_registry OMNIBIOAI_MODEL_REGISTRY_ROOT: /registry volumes: @@ -384,8 +406,8 @@ services: MYSQL_PORT: "3306" MYSQL_DATABASE: limsdb MYSQL_USER: root - MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} - DJANGO_SECRET_KEY: ${LIMSX_DJANGO_SECRET_KEY:-omnibioai-studio-secret} + MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + DJANGO_SECRET_KEY: ${LIMSX_DJANGO_SECRET_KEY:?LIMSX_DJANGO_SECRET_KEY must be set} DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost,lims,0.0.0.0,*" CORS_ALLOWED_ORIGINS: "https://webstudio.omnibioai.org,https://app.omnibioai.org,https://lims.omnibioai.org,https://omnibioai.org" REDIS_URL: redis://redis:6379/0 @@ -432,7 +454,7 @@ services: # os.environ.get("JWT_SECRET", "change-me") fallback silently # verified tokens against the public literal "change-me" instead of # the real secret. - JWT_SECRET: ${AUTH_SECRET_KEY:-change-me-in-production} + JWT_SECRET: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set} volumes: - ${DATA_DIR}:/workspace - /var/run/docker.sock:/var/run/docker.sock @@ -475,7 +497,7 @@ services: DB_PORT: "3306" DB_NAME: omnibioai DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} depends_on: mysql: condition: service_healthy @@ -506,7 +528,7 @@ services: REACT_APP_OMNIBIOAI_BASE_URL: ${OMNIBIOAI_BASE_URL:-http://localhost:8000} REACT_APP_OMNIBIOAI_TOKEN: ${OMNIBIOAI_TOKEN:-dev} REACT_APP_JUPYTER_BASE: ${JUPYTER_BASE:-http://localhost:8888} - REACT_APP_JUPYTER_TOKEN: ${JUPYTER_TOKEN:-omnibioai} + REACT_APP_JUPYTER_TOKEN: ${JUPYTER_TOKEN:?JUPYTER_TOKEN must be set} REACT_APP_USE_MOCK: "false" depends_on: workbench: @@ -526,7 +548,7 @@ services: - ${DATA_DIR}:/home/jovyan/data - ${WORK_DIR}:/home/jovyan/work environment: - JUPYTER_TOKEN: ${JUPYTER_TOKEN:-omnibioai} + JUPYTER_TOKEN: ${JUPYTER_TOKEN:?JUPYTER_TOKEN must be set} GRANT_SUDO: "yes" depends_on: workbench: @@ -542,7 +564,7 @@ services: - ${DATA_DIR}:/home/rstudio/data - ${WORK_DIR}:/home/rstudio/work environment: - PASSWORD: ${RSTUDIO_PASSWORD:-omnibioai} + PASSWORD: ${RSTUDIO_PASSWORD:?RSTUDIO_PASSWORD must be set} ROOT: "TRUE" depends_on: workbench: @@ -558,7 +580,7 @@ services: - ${DATA_DIR}:/home/coder/data - ${WORK_DIR}:/home/coder/work environment: - PASSWORD: ${VSCODE_PASSWORD:-omnibioai} + PASSWORD: ${VSCODE_PASSWORD:?VSCODE_PASSWORD must be set} depends_on: workbench: condition: service_started @@ -585,13 +607,17 @@ services: ports: - "${HOST_IP:-0.0.0.0}:8099:8099" environment: - LICENSE_SECRET: ${LICENSE_SECRET:-omnibioai-secret-change-in-production} - ADMIN_KEY: ${ADMIN_KEY:-admin-secret} + LICENSE_SECRET: ${LICENSE_SECRET:?LICENSE_SECRET must be set} + # PR11: ADMIN_KEY removed -- /api/license/generate and + # /api/license/list are now IAM-authorized (Bearer JWT + + # manage_licenses permission via omnibioai-auth), replacing the + # static shared-secret check. Kept in parity with + # docker-compose.release.yml -- see its comment for detail. GHCR_PULL_TOKEN: ${GHCR_PULL_TOKEN:-} MYSQL_HOST: mysql MYSQL_PORT: "3306" MYSQL_USER: ${MYSQL_USER:-root} - MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} MYSQL_DATABASE: omnibioai_licenses depends_on: mysql: @@ -618,7 +644,7 @@ services: ports: - "${HOST_IP:-0.0.0.0}:3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASSWORD:-omnibioai} + GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASSWORD:?GF_ADMIN_PASSWORD must be set} GF_USERS_ALLOW_SIGN_UP: "false" volumes: - grafana_data:/var/lib/grafana diff --git a/docker-compose.release.dev-ports.yml b/docker-compose.release.dev-ports.yml new file mode 100644 index 0000000..e247d31 --- /dev/null +++ b/docker-compose.release.dev-ports.yml @@ -0,0 +1,31 @@ +# Explicit development-only overlay for the RELEASE compose stack. +# +# docker-compose.release.yml (the production/default configuration) does not +# publish mysql or redis to the host -- see SECURITY-COMPOSE-HARDENING.md. +# If you need direct host access to them while running the release stack +# locally (e.g. inspecting data with a MySQL/Redis GUI client, or debugging +# a migration), layer this file on top explicitly: +# +# docker compose -f docker-compose.release.yml \ +# -f docker-compose.release.dev-ports.yml \ +# --env-file .env up -d +# +# Bound to 127.0.0.1 by default -- override MYSQL_DEV_HOST_IP / REDIS_DEV_HOST_IP +# in your .env only if you specifically need LAN/remote access, and understand +# that MySQL/Redis here have no independent auth beyond MYSQL_ROOT_PASSWORD / +# no auth at all (redis), respectively. +# +# This file is for local development only. It is never bundled into the +# packaged Electron app (electron-builder.json only ships +# docker-compose.release.yml) and must never be referenced from the +# production/default startup path (electron/main.js, scripts/start.sh). + +services: + + mysql: + ports: + - "${MYSQL_DEV_HOST_IP:-127.0.0.1}:3306:3306" + + redis: + ports: + - "${REDIS_DEV_HOST_IP:-127.0.0.1}:6380:6379" diff --git a/docker-compose.release.yml b/docker-compose.release.yml index 0528465..945e406 100644 --- a/docker-compose.release.yml +++ b/docker-compose.release.yml @@ -4,8 +4,19 @@ # # Setup: # cp .env.example .env -# # Edit .env with your paths and secrets -# docker compose -f docker-compose-release.yml up -d +# # Edit .env with your paths and secrets -- MYSQL_ROOT_PASSWORD, AUTH_SECRET_KEY, +# # LICENSE_SECRET, GF_ADMIN_PASSWORD, LIMSX_DJANGO_SECRET_KEY, JUPYTER_TOKEN, +# # RSTUDIO_PASSWORD, and VSCODE_PASSWORD are REQUIRED -- compose refuses to +# # start without them (see SECURITY-COMPOSE-HARDENING.md). The Studio +# # Electron app generates these for you on first launch; running this file +# # standalone (scripts/start.sh, or invoking `docker compose` by hand) +# # needs its own .env with all of them set. +# docker compose -f docker-compose.release.yml up -d +# +# This is the production/default configuration: mysql and redis are NOT +# published to the host. For local development access to them, layer the +# explicit dev overlay: +# docker compose -f docker-compose.release.yml -f docker-compose.release.dev-ports.yml up -d services: @@ -13,13 +24,20 @@ services: # Infrastructure # --------------------------------------------------------------------------- + # Security boundary (see SECURITY-COMPOSE-HARDENING.md): mysql/redis are + # intentionally NOT published to the host in this release/default + # configuration -- every consumer already reaches them over the internal + # Docker network by service name (mysql:3306, redis:6379), and DEPLOYMENT.md + # has documented "MySQL: internal only, not exposed externally in prod" as + # the intended design since before this fix -- the compose file just didn't + # implement it. Need host access for local debugging? Use the explicit dev + # overlay: docker compose -f docker-compose.release.yml + # -f docker-compose.release.dev-ports.yml up -d mysql: image: mysql:8.0 environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set -- see DEPLOYMENT.md. Refusing to start with a silently-guessable default} MYSQL_DATABASE: ${MYSQL_DEFAULT_DB:-omnibioai} - ports: - - "3306:3306" volumes: - mysql_data:/var/lib/mysql - ${DB_INIT_DIR}:/docker-entrypoint-initdb.d:ro @@ -32,8 +50,6 @@ services: redis: image: redis:7-alpine - ports: - - "6380:6379" volumes: - redis_data:/data healthcheck: @@ -72,8 +88,8 @@ services: DB_PORT: "3306" DB_NAME: omnibioai DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} - SECRET_KEY: ${AUTH_SECRET_KEY:-change-me-in-production} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + SECRET_KEY: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set} ACCESS_TOKEN_EXPIRE_MINUTES: "15" REFRESH_TOKEN_EXPIRE_DAYS: "7" REDIS_URL: redis://redis:6379 @@ -114,7 +130,7 @@ services: DB_HOST: mysql DB_PORT: "3306" DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} DB_NAME: ${MYSQL_DEFAULT_DB:-omnibioai} depends_on: mysql: @@ -134,7 +150,7 @@ services: # api/deps.py::require_platform_admin and # audit/identity.py::validate_identity_token both fell back to the # public literal "change-me" instead of the real secret. - JWT_SECRET: ${AUTH_SECRET_KEY:-change-me-in-production} + JWT_SECRET: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set} # PR-B0: same fix as docker-compose.yml -- GET /audit/events was # falling back to an unreachable localhost DB URL. NOTE: this file # intentionally does NOT add a security-audit-worker service (see @@ -143,7 +159,7 @@ services: # to fall back on the way docker-compose.yml's billing-worker does. # Flagged as an explicit follow-up in the PR-B0 report, not silently # left broken. - AUDIT_DATABASE_URL: mysql+pymysql://root:${MYSQL_ROOT_PASSWORD:-omnibioai}@mysql:3306/omnibioai_audit + AUDIT_DATABASE_URL: mysql+pymysql://root:${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}@mysql:3306/omnibioai_audit depends_on: redis: condition: service_healthy @@ -162,7 +178,7 @@ services: POLICY_URL: http://policy-engine:8002 HPC_URL: http://hpc-policy-engine:8003 AUDIT_URL: http://security-audit:8004 - JWT_SECRET: ${AUTH_SECRET_KEY:-change-me-in-production} + JWT_SECRET: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set} TES_URL: http://tes:8081 WORKBENCH_URL: http://workbench:8000 TOOLSERVER_URL: http://toolserver:9090 @@ -264,7 +280,7 @@ services: DB_PORT: "3306" DB_NAME: omnibioai DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} REDIS_HOST: redis REDIS_PORT: "6380" CELERY_BROKER_URL: redis://redis:6379/1 @@ -284,7 +300,7 @@ services: OMNIBIOAI_MYSQL_HOST: mysql OMNIBIOAI_MYSQL_PORT: "3306" OMNIBIOAI_MYSQL_USER: root - OMNIBIOAI_MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + OMNIBIOAI_MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} OMNIBIOAI_MYSQL_DB: omnibioai GATEWAY_URL: http://api-gateway:8080 RAGBIO_API_KEY: ${RAGBIO_API_KEY:-} @@ -329,11 +345,20 @@ services: DB_PORT: "3306" DB_NAME: omnibioai DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} REDIS_HOST: redis REDIS_PORT: "6380" CELERY_BROKER_URL: redis://redis:6379/1 TES_BASE_URL: http://tes:8081 + # #196: fixed call sites read GATEWAY_URL exclusively -- was already + # wired into docker-compose.yml (dev) but missing here, so the + # packaged release build never got the gateway-routed fix, only the + # dev stack did. TES_BASE_URL above stays pointed directly at TES + # (not repointed to the gateway) -- other readers of it in this same + # image have no bearer token attached and would silently 401 if this + # var suddenly routed through gateway's AuthMiddleware, same reasoning + # as docker-compose.yml's own comment on this line. + GATEWAY_URL: http://api-gateway:8080 TOOLSERVER_BASE_URL: http://toolserver:9090 MODEL_REGISTRY_BASE_URL: http://model-registry:8095 RAG_BASE_URL: http://rag:8096 @@ -373,7 +398,7 @@ services: DB_HOST: mysql DB_PORT: "3306" DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} DB_NAME: model_registry OMNIBIOAI_MODEL_REGISTRY_ROOT: /registry volumes: @@ -395,8 +420,8 @@ services: MYSQL_PORT: "3306" MYSQL_DATABASE: limsdb MYSQL_USER: root - MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} - DJANGO_SECRET_KEY: ${LIMSX_DJANGO_SECRET_KEY:-omnibioai-studio-secret} + MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + DJANGO_SECRET_KEY: ${LIMSX_DJANGO_SECRET_KEY:?LIMSX_DJANGO_SECRET_KEY must be set} FIELD_ENCRYPTION_KEY: ${LIMSX_FIELD_ENCRYPTION_KEY} DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost,lims,0.0.0.0,*" CORS_ALLOWED_ORIGINS: "https://webstudio.omnibioai.org,https://app.omnibioai.org,https://lims.omnibioai.org,https://omnibioai.org" @@ -444,7 +469,7 @@ services: # os.environ.get("JWT_SECRET", "change-me") fallback silently # verified tokens against the public literal "change-me" instead of # the real secret. - JWT_SECRET: ${AUTH_SECRET_KEY:-change-me-in-production} + JWT_SECRET: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set} volumes: - ${DATA_DIR}:/workspace - /var/run/docker.sock:/var/run/docker.sock @@ -470,7 +495,7 @@ services: OLLAMA_URL: http://ollama:11434/api OLLAMA_HOST: http://ollama:11434 REPO_BASE: /repos - JWT_SECRET: ${AUTH_SECRET_KEY:-change-me-in-production} + JWT_SECRET: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set} AUTH_ENABLED: "true" volumes: - ${DATA_DIR}/dev-hub:/app/data @@ -489,7 +514,7 @@ services: DB_PORT: "3306" DB_NAME: omnibioai DB_USER: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} depends_on: mysql: condition: service_healthy @@ -520,7 +545,7 @@ services: REACT_APP_OMNIBIOAI_BASE_URL: ${OMNIBIOAI_BASE_URL:-http://localhost:8000} REACT_APP_OMNIBIOAI_TOKEN: ${OMNIBIOAI_TOKEN:-dev} REACT_APP_JUPYTER_BASE: ${JUPYTER_BASE:-http://localhost:8888} - REACT_APP_JUPYTER_TOKEN: ${JUPYTER_TOKEN:-omnibioai} + REACT_APP_JUPYTER_TOKEN: ${JUPYTER_TOKEN:?JUPYTER_TOKEN must be set} REACT_APP_USE_MOCK: "false" depends_on: workbench: @@ -540,7 +565,7 @@ services: - ${DATA_DIR}:/home/jovyan/data - ${WORK_DIR}:/home/jovyan/work environment: - JUPYTER_TOKEN: ${JUPYTER_TOKEN:-omnibioai} + JUPYTER_TOKEN: ${JUPYTER_TOKEN:?JUPYTER_TOKEN must be set} GRANT_SUDO: "yes" depends_on: workbench: @@ -556,7 +581,7 @@ services: - ${DATA_DIR}:/home/rstudio/data - ${WORK_DIR}:/home/rstudio/work environment: - PASSWORD: ${RSTUDIO_PASSWORD:-omnibioai} + PASSWORD: ${RSTUDIO_PASSWORD:?RSTUDIO_PASSWORD must be set} ROOT: "TRUE" depends_on: workbench: @@ -572,7 +597,7 @@ services: - ${DATA_DIR}:/home/coder/data - ${WORK_DIR}:/home/coder/work environment: - PASSWORD: ${VSCODE_PASSWORD:-omnibioai} + PASSWORD: ${VSCODE_PASSWORD:?VSCODE_PASSWORD must be set} depends_on: workbench: condition: service_started @@ -599,13 +624,20 @@ services: ports: - "${HOST_IP:-0.0.0.0}:8099:8099" environment: - LICENSE_SECRET: ${LICENSE_SECRET:-omnibioai-secret-change-in-production} - ADMIN_KEY: ${ADMIN_KEY:-admin-secret} + LICENSE_SECRET: ${LICENSE_SECRET:?LICENSE_SECRET must be set} + # PR11: ADMIN_KEY removed -- /api/license/generate and + # /api/license/list are now IAM-authorized (Bearer JWT + + # manage_licenses permission via omnibioai-auth), replacing the + # static shared-secret check. docker-compose.yml dropped this at the + # time; both release files kept passing a dead ${ADMIN_KEY:-admin-secret} + # that license_server.py no longer reads (confirmed: zero references + # in backend/license_server.py). Removed here rather than converted to + # a required var -- it has no consumer left to require it. GHCR_PULL_TOKEN: ${GHCR_PULL_TOKEN:-} MYSQL_HOST: mysql MYSQL_PORT: "3306" MYSQL_USER: ${MYSQL_USER:-root} - MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} MYSQL_DATABASE: omnibioai_licenses depends_on: mysql: @@ -632,7 +664,7 @@ services: ports: - "${HOST_IP:-0.0.0.0}:3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASSWORD:-omnibioai} + GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASSWORD:?GF_ADMIN_PASSWORD must be set} GF_USERS_ALLOW_SIGN_UP: "false" volumes: - grafana_data:/var/lib/grafana From fad98ffe5ba3648557e77426961da1a5acaeb276 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 12:04:28 -0500 Subject: [PATCH 2/5] security(secrets): require production credentials, fail closed on absence Every credential in the release compose files fell back to a public literal committed to this repository: MYSQL_ROOT_PASSWORD -> omnibioai AUTH_SECRET_KEY -> change-me-in-production LICENSE_SECRET -> omnibioai-secret-change-in-production GF_ADMIN_PASSWORD -> omnibioai LIMSX_DJANGO_SECRET_KEY -> omnibioai-studio-secret JUPYTER_TOKEN -> omnibioai RSTUDIO_PASSWORD -> omnibioai VSCODE_PASSWORD -> omnibioai A deployment that simply didn't set them came up fully functional with guessable credentials rather than failing. All now use compose's ${VAR:?message} required form, so a missing value aborts startup. No replacement secrets are hard-coded -- values come from the environment only. Two real bugs surfaced while tracing where these are provisioned: 1. LIMSX_DJANGO_SECRET_KEY, JUPYTER_TOKEN, RSTUDIO_PASSWORD, and VSCODE_PASSWORD were never in the app's generation list at all, and writeEnvFile() hardcoded the first one's weak literal as a fallback. Every Studio installation everywhere therefore shared the same four values -- including the key signing LIMS's session cookies. They are generated per-installation now (32 random bytes each), and the remaining weak writeEnvFile() fallbacks are gone so a missing value fails closed via the compose guard instead of silently reappearing. 2. scripts/start.sh defaulted MYSQL_ROOT_PASSWORD to "omnibioai" in its "no .env found" branch, which would have re-supplied the weak value and defeated the new guard entirely. Removed. ADMIN_KEY is removed rather than made required: PR11 replaced the license server's static shared-secret check with IAM authorization and backend/license_server.py has had zero references to it since, but both release files still passed a dead ${ADMIN_KEY:-admin-secret}. generateSecrets/parseEnvFile move to electron/secrets.js -- same behavior, but with no `electron` import so the logic is unit-testable outside an Electron runtime. Settings' credential panel surfaces the three newly-generated values so they remain retrievable by the user. Co-Authored-By: Claude Sonnet 5 --- electron/main.js | 62 ++++++++++--------------------- electron/secrets.js | 77 +++++++++++++++++++++++++++++++++++++++ scripts/start.sh | 9 ++++- src/ui/pages/Settings.jsx | 3 ++ 4 files changed, 107 insertions(+), 44 deletions(-) create mode 100644 electron/secrets.js diff --git a/electron/main.js b/electron/main.js index 78db7bd..5347d14 100644 --- a/electron/main.js +++ b/electron/main.js @@ -6,6 +6,7 @@ const { spawn, execFile } = require("child_process"); const os = require("os"); const crypto = require("crypto"); const { initAutoUpdater } = require("./updater"); +const { generateSecrets, parseEnvFile } = require("./secrets"); // In packaged app (DMG/AppImage/EXE) → always production mode // In dev (npm run dev) → use env var @@ -129,39 +130,8 @@ function ensureDbInit() { } // ─── SECRET GENERATION ──────────────────────────────────────────────────────── -function generateSecrets(envPath) { - const defaults = { - AUTH_SECRET_KEY: 'change-me', - MYSQL_ROOT_PASSWORD: 'omnibioai', - GF_ADMIN_PASSWORD: 'omnibioai', - LICENSE_SECRET: 'omnibioai-secret-change-in-production', - ADMIN_KEY: 'admin-secret', - }; - - let env = {}; - if (fs.existsSync(envPath)) { - fs.readFileSync(envPath, 'utf8').split('\n').forEach(line => { - const [k, ...v] = line.split('='); - if (k) env[k.trim()] = v.join('=').trim(); - }); - } - - let changed = false; - for (const [key, defaultVal] of Object.entries(defaults)) { - if (!env[key] || env[key] === defaultVal) { - env[key] = crypto.randomBytes(32).toString('hex'); - changed = true; - } - } - - if (changed) { - const content = Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n'); - fs.mkdirSync(path.dirname(envPath), { recursive: true }); - fs.writeFileSync(envPath, content + '\n'); - return true; - } - return false; -} +// generateSecrets/parseEnvFile now live in ./secrets.js -- pure logic, no +// `electron` import, so it's unit-testable outside an Electron runtime. // ─── DOCKER HELPERS ─────────────────────────────────────────────────────────── function composeArgs(...extra) { @@ -195,15 +165,15 @@ function writeEnvFile(config) { const dataDir = settings.data_dir || path.join(home, "omnibioai", "data"); const workDir = settings.work_dir || path.join(home, "omnibioai", "work"); - // Preserve any generated secrets already written by generateSecrets() + // Preserve any generated secrets already written by generateSecrets() -- + // never fall back to the historical weak literals here. By the time this + // ever runs in the normal app lifecycle, generateSecrets() has already + // populated every one of these at app startup (see app.whenReady() below); + // an empty fallback means a genuinely missing value fails closed via + // docker-compose.release.yml's ${VAR:?...} guard instead of silently + // reintroducing a known-weak default. const envPath = getEnvPath(); - const existing = {}; - if (fs.existsSync(envPath)) { - fs.readFileSync(envPath, 'utf8').split('\n').forEach(line => { - const [k, ...v] = line.split('='); - if (k) existing[k.trim()] = v.join('=').trim(); - }); - } + const existing = parseEnvFile(envPath); const lines = [ `HOST_IP=0.0.0.0`, @@ -221,13 +191,16 @@ function writeEnvFile(config) { `MACHINE_DIR=${path.dirname(path.dirname(workDir))}`, `DB_INIT_DIR=${getDbInitPath()}`, `VIDEO_DIR=${workDir}/videos`, - `MYSQL_ROOT_PASSWORD=${existing.MYSQL_ROOT_PASSWORD || 'omnibioai'}`, + `MYSQL_ROOT_PASSWORD=${existing.MYSQL_ROOT_PASSWORD || ''}`, `MYSQL_DEFAULT_DB=omnibioai`, - `LIMSX_DJANGO_SECRET_KEY=${existing.LIMSX_DJANGO_SECRET_KEY || 'omnibioai-studio-secret'}`, + `LIMSX_DJANGO_SECRET_KEY=${existing.LIMSX_DJANGO_SECRET_KEY || ''}`, `AUTH_SECRET_KEY=${existing.AUTH_SECRET_KEY || ''}`, `GF_ADMIN_PASSWORD=${existing.GF_ADMIN_PASSWORD || ''}`, `GF_STUDIO_TOKEN=${existing.GF_STUDIO_TOKEN || ''}`, `LICENSE_SECRET=${existing.LICENSE_SECRET || ''}`, + `JUPYTER_TOKEN=${existing.JUPYTER_TOKEN || ''}`, + `RSTUDIO_PASSWORD=${existing.RSTUDIO_PASSWORD || ''}`, + `VSCODE_PASSWORD=${existing.VSCODE_PASSWORD || ''}`, `ADMIN_KEY=${existing.ADMIN_KEY || ''}`, ]; @@ -737,6 +710,9 @@ ipcMain.handle('get-credentials', async () => { grafanaToken: env.GF_STUDIO_TOKEN || '', mysqlPassword: env.MYSQL_ROOT_PASSWORD || '', authSecretKey: env.AUTH_SECRET_KEY || '', + jupyterToken: env.JUPYTER_TOKEN || '', + rstudioPassword: env.RSTUDIO_PASSWORD || '', + vscodePassword: env.VSCODE_PASSWORD || '', envPath, }; }); diff --git a/electron/secrets.js b/electron/secrets.js new file mode 100644 index 0000000..385c25b --- /dev/null +++ b/electron/secrets.js @@ -0,0 +1,77 @@ +// Pure, dependency-free secret-generation logic for the Studio release .env +// file. Deliberately has zero `require("electron")` (or any other Electron +// API) so it can be unit-tested with a plain Node runtime -- see +// tests/test_secret_generation.js. Extracted out of electron/main.js, same +// behavior, no functional change to the app. + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +// Every credential the Electron app is responsible for provisioning before +// docker-compose.release.yml's own ${VAR:?...} required-var guards run -- +// keep this list in sync with that file's required vars (see +// SECURITY-COMPOSE-HARDENING.md). Each value is the historical known-weak +// literal the release compose file used to silently fall back to; any +// existing .env value that still matches one of these gets rotated to a +// fresh random secret, same as a genuinely-unset value. +// +// LIMSX_DJANGO_SECRET_KEY, JUPYTER_TOKEN, RSTUDIO_PASSWORD, and +// VSCODE_PASSWORD were missing from this map for every release prior to +// this fix -- every installation of Studio was silently using the same +// hardcoded literal for each across every customer (LIMS's self-issued +// session-cookie signing key, and the Jupyter/RStudio/VSCode terminal +// credentials), since nothing ever rotated them. Added here to close that +// gap -- see docker-compose.release.yml's matching ${VAR:?...} guards. +const SECRET_DEFAULTS = { + AUTH_SECRET_KEY: "change-me", + MYSQL_ROOT_PASSWORD: "omnibioai", + GF_ADMIN_PASSWORD: "omnibioai", + LICENSE_SECRET: "omnibioai-secret-change-in-production", + LIMSX_DJANGO_SECRET_KEY: "omnibioai-studio-secret", + JUPYTER_TOKEN: "omnibioai", + RSTUDIO_PASSWORD: "omnibioai", + VSCODE_PASSWORD: "omnibioai", + ADMIN_KEY: "admin-secret", +}; + +function parseEnvFile(envPath) { + const env = {}; + if (fs.existsSync(envPath)) { + fs.readFileSync(envPath, "utf8") + .split("\n") + .forEach((line) => { + const [k, ...v] = line.split("="); + if (k) env[k.trim()] = v.join("=").trim(); + }); + } + return env; +} + +// Rotates any unset/still-default secret in envPath to a fresh random +// 32-byte hex value, in place. Returns true if the file was written +// (something changed), false if every secret already held a real, +// previously-generated value. +function generateSecrets(envPath) { + const env = parseEnvFile(envPath); + let changed = false; + + for (const [key, defaultVal] of Object.entries(SECRET_DEFAULTS)) { + if (!env[key] || env[key] === defaultVal) { + env[key] = crypto.randomBytes(32).toString("hex"); + changed = true; + } + } + + if (changed) { + const content = Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); + fs.mkdirSync(path.dirname(envPath), { recursive: true }); + fs.writeFileSync(envPath, content + "\n"); + } + + return changed; +} + +module.exports = { SECRET_DEFAULTS, parseEnvFile, generateSecrets }; diff --git a/scripts/start.sh b/scripts/start.sh index 8fea788..525bd71 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -58,7 +58,14 @@ else export WORKSPACE_HOST="${WORKSPACE_HOST:-$HOME/omnibioai/work}" export DB_INIT_DIR="$SCRIPT_DIR/../db-init" export VIDEO_DIR="$WORK_DIR/videos" - export MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-omnibioai}" + # Deliberately NOT defaulted to the historical "omnibioai" literal here -- + # docker-compose.release.yml now refuses to start without a real + # MYSQL_ROOT_PASSWORD (and the other required secrets), so an unset value + # correctly fails loudly at `docker compose up` below instead of silently + # provisioning a guessable production database password. Set it in your + # shell environment, or (preferred) run the Studio app once first so it + # generates a real .env for this script to pick up next time. + export MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}" export MYSQL_DEFAULT_DB="${MYSQL_DEFAULT_DB:-omnibioai}" export HOST_IP="0.0.0.0" fi diff --git a/src/ui/pages/Settings.jsx b/src/ui/pages/Settings.jsx index c11628e..da134dd 100644 --- a/src/ui/pages/Settings.jsx +++ b/src/ui/pages/Settings.jsx @@ -369,6 +369,9 @@ function SettingsConsole({ config, setConfig }) { { label:'Grafana Admin Password', key:'grafanaPassword', value: creds?.grafanaPassword }, { label:'MySQL Root Password', key:'mysqlPassword', value: creds?.mysqlPassword }, { label:'API Secret Key', key:'authSecretKey', value: creds?.authSecretKey }, + { label:'Jupyter Token', key:'jupyterToken', value: creds?.jupyterToken }, + { label:'RStudio Password', key:'rstudioPassword', value: creds?.rstudioPassword }, + { label:'VS Code Password', key:'vscodePassword', value: creds?.vscodePassword }, ].map(({ label, key, value }) => (
From 61f37c2aa082331eb6074c38d0fc1e75e87fda22 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 12:04:39 -0500 Subject: [PATCH 3/5] test(security): regression cover network boundary + credential requirements Static tests (no daemon, no live services) over both release compose files, matching the existing test_compose_release_config.py approach: - mysql/redis publish no ports in the release configuration - both still exist and consumers still address them by internal service name, so the fix can't be "achieved" by breaking internal connectivity - api-gateway stays published -- guards against over-applying the exposure fix to the one service meant to be reachable - no ${VAR:-weak} fallback survives for any required secret, and each uses the ${VAR:?} fail-closed form - the dev overlay republishes both, defaults to loopback, is not bundled by electron-builder, and is referenced by neither production startup path - docker-compose.yml still publishes for local dev, pinning the dev/release distinction so the two files' roles stay legible Node built-in test runner (no new dependency) covers electron/secrets.js: generation into a fresh .env, rotation of known-weak literals, preservation of already-real secrets across launches, non-clobbering of unrelated keys, and distinctness both per-key and per-install. Asserts on properties only -- never prints a generated value. One test caught a real remaining issue while being written: the dead ${ADMIN_KEY:-admin-secret} still present in both release files, fixed in the preceding commit. CI now runs both suites, validates all three compose files (release, dash-release, and the dev overlay merged on top), and asserts the negative case -- that both release files *refuse* to validate when required secrets are absent, so the fail-closed guard can't silently regress. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 40 ++++ package.json | 3 +- tests/test_compose_network_exposure.py | 301 +++++++++++++++++++++++++ tests/test_secret_generation.js | 173 ++++++++++++++ 4 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 tests/test_compose_network_exposure.py create mode 100644 tests/test_secret_generation.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a201c3..3a9820d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,13 @@ jobs: && echo "✅ package.json valid" node -e "JSON.parse(require('fs').readFileSync('electron-builder.json'))" \ && echo "✅ electron-builder.json valid" + - name: Secret-generation unit tests + run: npm run test:secrets + - name: Compose security regression tests + run: | + python3 -m pip install --quiet pyyaml pytest + python3 -m pytest tests/test_compose_network_exposure.py \ + tests/test_compose_release_config.py -q - name: Create .env for compose validation run: | cat > .env << 'ENVEOF' @@ -45,11 +52,44 @@ jobs: NCBI_EMAIL=ci@omnibioai.org MYSQL_ROOT_PASSWORD=cipassword AUTH_SECRET_KEY=cikey + LICENSE_SECRET=cilicense + GF_ADMIN_PASSWORD=cigrafana + LIMSX_DJANGO_SECRET_KEY=cilims + JUPYTER_TOKEN=cijupyter + RSTUDIO_PASSWORD=cirstudio + VSCODE_PASSWORD=civscode ENVEOF - name: Validate docker-compose run: | docker compose -f docker-compose.yml config --quiet \ && echo "✅ docker-compose.yml valid" + - name: Validate release compose files + # docker-compose.release.yml is the file electron-builder actually + # bundles into packaged installers; docker-compose-release.yml (dash) + # is kept in parity with it (see tests/test_compose_release_config.py) + # for anyone still invoking it directly. Both must validate with a + # full required-secret .env, and both must confirm they're + # unpublishable without one -- see SECURITY-COMPOSE-HARDENING.md. + run: | + for f in docker-compose.release.yml docker-compose-release.yml; do + docker compose -f "$f" config --quiet \ + && echo "✅ $f valid with required secrets set" + done + + # dev-only overlay must also merge cleanly on top of the release default + docker compose -f docker-compose.release.yml -f docker-compose.release.dev-ports.yml config --quiet \ + && echo "✅ docker-compose.release.dev-ports.yml merges cleanly" + - name: Release compose must fail closed without required secrets + run: | + grep -v -E '^(MYSQL_ROOT_PASSWORD|AUTH_SECRET_KEY|LICENSE_SECRET|GF_ADMIN_PASSWORD|LIMSX_DJANGO_SECRET_KEY|JUPYTER_TOKEN|RSTUDIO_PASSWORD|VSCODE_PASSWORD)=' .env > /tmp/missing-secrets.env + for f in docker-compose.release.yml docker-compose-release.yml; do + if docker compose --env-file /tmp/missing-secrets.env -f "$f" config --quiet 2>/tmp/err.log; then + echo "❌ $f started without required secrets -- fail-closed guard regressed" + exit 1 + fi + grep -q "required variable" /tmp/err.log \ + && echo "✅ $f correctly refuses to start without required secrets" + done # ── 2. Build React UI ─────────────────────────────────── build-ui: diff --git a/package.json b/package.json index e9cf10b..957516d 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "web": "vite --mode web", "web:build": "vite build --mode web", "web:preview": "vite preview --mode web --port 4173", - "check-routes": "python3 scripts/check_route_drift.py" + "check-routes": "python3 scripts/check_route_drift.py", + "test:secrets": "node --test tests/test_secret_generation.js" }, "dependencies": { "@omnibioai/design-tokens": "file:./packages/omnibioai-design-tokens", diff --git a/tests/test_compose_network_exposure.py b/tests/test_compose_network_exposure.py new file mode 100644 index 0000000..22c3663 --- /dev/null +++ b/tests/test_compose_network_exposure.py @@ -0,0 +1,301 @@ +"""HIPAA infrastructure-exposure hardening: static regression tests for the +release compose files' network boundary and required-credential handling. + +Purely static (no live services, no docker daemon) -- parses the compose +YAML directly, same approach as test_compose_release_config.py alongside it. + +Guards the three things this workstream fixed, each of which was a silent +High-severity exposure in the shipped release configuration: + +1. mysql published "3306:3306" and redis published "6380:6379" with no host + IP binding at all, i.e. 0.0.0.0 -- reachable from anywhere routable to + the host, bypassing the API Gateway, every service's IAM/authorization + layer, and all organization-isolation enforcement, straight to raw + tenant data. DEPLOYMENT.md's own port table already described MySQL as + "internal only -- not exposed externally in prod"; the compose file just + never implemented that. + +2. Every credential fell back to a public, committed-to-the-repo literal + (${MYSQL_ROOT_PASSWORD:-omnibioai}, ${AUTH_SECRET_KEY:-change-me-in-production}, + ...), so a deployment that simply didn't set them came up fully + functional with guessable production credentials rather than failing. + Combined with (1), root MySQL was reachable at a known password. + +3. celery-worker never received GATEWAY_URL in the release files (only in + the dev docker-compose.yml), so the packaged build's #196 gateway + routing silently never took effect. + +Development access to mysql/redis is preserved through an explicit, +separate overlay file rather than by weakening the release default -- see +SECURITY-COMPOSE-HARDENING.md. +""" +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# The file electron-builder/electron actually bundle into packaged +# installers, plus the dash-named legacy file kept in parity with it. Both +# are release/production configurations and must hold the same boundary. +RELEASE_COMPOSE_PATHS = [ + REPO_ROOT / "docker-compose.release.yml", + REPO_ROOT / "docker-compose-release.yml", +] + +DEV_PORTS_OVERLAY = REPO_ROOT / "docker-compose.release.dev-ports.yml" +DEV_COMPOSE = REPO_ROOT / "docker-compose.yml" + +# Datastores that must never be published to the host by a release config. +# Both hold multi-tenant data directly and neither sits behind the API +# Gateway -- reaching them at all is reaching them past every authorization +# control the platform has. +NON_PUBLISHABLE_SERVICES = ["mysql", "redis"] + +# Credentials that must be operator-provided in a release deployment, with +# no fallback. Value is the historical weak literal each used to default to +# -- asserted absent so a future edit can't quietly reinstate one. +REQUIRED_SECRETS = { + "MYSQL_ROOT_PASSWORD": "omnibioai", + "AUTH_SECRET_KEY": "change-me-in-production", + "LICENSE_SECRET": "omnibioai-secret-change-in-production", + "GF_ADMIN_PASSWORD": "omnibioai", + "LIMSX_DJANGO_SECRET_KEY": "omnibioai-studio-secret", + "JUPYTER_TOKEN": "omnibioai", + "RSTUDIO_PASSWORD": "omnibioai", + "VSCODE_PASSWORD": "omnibioai", +} + + +def _load(path): + with open(path) as f: + return yaml.safe_load(f) + + +def _config_only(path): + """File text with comment lines stripped. These files carry long + explanatory comments that legitimately quote the very weak-default + expressions being asserted against (documenting what was removed and + why), so substring checks must look at actual configuration, not prose.""" + return "\n".join( + line + for line in path.read_text().splitlines() + if not line.lstrip().startswith("#") + ) + + +@pytest.fixture( + scope="module", params=RELEASE_COMPOSE_PATHS, ids=lambda p: p.name +) +def release_compose(request): + return _load(request.param) + + +@pytest.fixture( + scope="module", params=RELEASE_COMPOSE_PATHS, ids=lambda p: p.name +) +def release_compose_text(request): + return _config_only(request.param) + + +# ── 1. Network exposure ────────────────────────────────────────────────── + + +@pytest.mark.parametrize("service", NON_PUBLISHABLE_SERVICES) +def test_datastore_not_published_to_host(release_compose, service): + """No `ports:` on mysql/redis at all in a release config. Containers on + the compose network still reach them by service name -- publishing is + purely about host/external reachability.""" + svc = release_compose["services"][service] + published = svc.get("ports") + assert not published, ( + f"{service} must not publish ports in the release configuration -- " + f"found {published}. This exposes a multi-tenant datastore directly " + f"to the host/network, bypassing the API Gateway and every " + f"application-layer authorization and org-isolation control. Need " + f"local access? Use docker-compose.release.dev-ports.yml explicitly." + ) + + +@pytest.mark.parametrize("service", NON_PUBLISHABLE_SERVICES) +def test_datastore_still_reachable_internally(release_compose, service): + """Hardening must not have removed the service itself -- the internal + Docker-network path every consumer depends on has to stay intact.""" + assert service in release_compose["services"], ( + f"{service} must still exist as a service -- removing host port " + f"publication must not remove internal network reachability" + ) + + +def test_consumers_still_point_at_internal_datastore_hostnames(release_compose): + """Backend services must still address mysql/redis by their internal + compose service names. A regression here (e.g. someone 'fixing' a + connection error by pointing at localhost/host.docker.internal) would + break the very boundary this suite protects.""" + services = release_compose["services"] + + db_consumers = [ + name + for name, svc in services.items() + if "DB_HOST" in (svc.get("environment") or {}) + ] + assert db_consumers, "expected at least one DB_HOST consumer" + + for name in db_consumers: + assert services[name]["environment"]["DB_HOST"] == "mysql", ( + f"{name} must reach the database over the internal Docker " + f"network as 'mysql', not via a host-published port" + ) + + +# ── 2. Credentials ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("var,weak_default", REQUIRED_SECRETS.items()) +def test_no_weak_credential_defaults(release_compose_text, var, weak_default): + """`${VAR:-weak}` must not appear for any required secret.""" + assert f"${{{var}:-{weak_default}}}" not in release_compose_text, ( + f"{var} must not fall back to the public literal '{weak_default}' -- " + f"a deployment that doesn't set it would come up fully functional " + f"with a credential that is committed to this repository" + ) + # Catch a *different* hardcoded fallback being substituted too. + assert f"${{{var}:-" not in release_compose_text, ( + f"{var} must have no default at all in a release configuration -- " + f"use ${{{var}:?...}} so a missing value fails closed" + ) + + +@pytest.mark.parametrize("var", REQUIRED_SECRETS) +def test_required_secrets_fail_closed(release_compose_text, var): + """Every required secret must use compose's `:?` required-variable form, + so `docker compose up`/`config` errors out rather than starting with a + silently-missing or guessable credential.""" + assert f"${{{var}:?" in release_compose_text, ( + f"{var} must use the ${{{var}:?message}} required-variable form so " + f"a deployment missing it fails closed instead of silently " + f"provisioning a weak or empty credential" + ) + + +def test_no_literal_weak_credentials_anywhere(release_compose_text): + """Belt-and-braces: none of the classic weak credential pairs should + appear as literal values anywhere in a release compose file.""" + for bad in ("root:root@", "admin:admin", "mysql:mysql", ":-admin-secret"): + assert bad not in release_compose_text, ( + f"weak credential literal {bad!r} present in release config" + ) + + +# ── 3. Gateway-first routing ───────────────────────────────────────────── + + +def test_celery_worker_receives_gateway_url(release_compose): + """#196's gateway-routed call sites read GATEWAY_URL exclusively. It was + wired into the dev compose but missing from both release files, so the + packaged build never actually got that fix.""" + env = release_compose["services"]["celery-worker"]["environment"] + assert env.get("GATEWAY_URL") == "http://api-gateway:8080", ( + "celery-worker must receive GATEWAY_URL so #196's gateway-routed " + "call sites reach the gateway in packaged/release builds, not only " + "in the dev compose stack" + ) + + +def test_gateway_is_addressed_internally(release_compose): + """Services reach the gateway over the private Docker network. The + gateway is the externally-published entry point; its *clients* inside + the compose network address it by service name.""" + services = release_compose["services"] + for name, svc in services.items(): + gateway_url = (svc.get("environment") or {}).get("GATEWAY_URL") + if gateway_url: + assert gateway_url == "http://api-gateway:8080", ( + f"{name}'s GATEWAY_URL must address the gateway by its " + f"internal service name, got {gateway_url!r}" + ) + + +def test_api_gateway_remains_published(release_compose): + """The gateway is the intended zero-trust entry point -- it must stay + reachable. This guards against over-correcting the exposure fix by + unpublishing the one service that is supposed to be published.""" + ports = release_compose["services"]["api-gateway"].get("ports") + assert ports, ( + "api-gateway must remain published -- it is the intended external " + "entry point that the unpublished backends sit behind" + ) + + +# ── 4. Development override ────────────────────────────────────────────── + + +def test_dev_ports_overlay_exists(): + assert DEV_PORTS_OVERLAY.exists(), ( + "an explicit development-only overlay must exist so local datastore " + "access is achievable without weakening the release default" + ) + + +@pytest.mark.parametrize("service", NON_PUBLISHABLE_SERVICES) +def test_dev_overlay_restores_local_access(service): + """The documented dev workflow must actually work -- the overlay has to + republish both datastores, or developers will just edit the release file + instead, which is exactly the failure mode this design prevents.""" + overlay = _load(DEV_PORTS_OVERLAY) + ports = overlay["services"][service].get("ports") + assert ports, f"dev overlay must republish {service} for local access" + + +@pytest.mark.parametrize("service", NON_PUBLISHABLE_SERVICES) +def test_dev_overlay_binds_loopback_by_default(service): + """Even the dev exception defaults to 127.0.0.1, not 0.0.0.0 -- a + developer enabling local access shouldn't thereby expose a datastore to + their whole LAN.""" + overlay = _load(DEV_PORTS_OVERLAY) + for mapping in overlay["services"][service]["ports"]: + assert str(mapping).startswith("${"), ( + f"{service} dev port mapping should bind through an overridable " + f"host-IP variable, got {mapping!r}" + ) + assert "127.0.0.1" in str(mapping), ( + f"{service} dev port mapping must default to loopback " + f"(127.0.0.1), got {mapping!r}" + ) + + +def test_dev_overlay_is_not_bundled_into_packaged_app(): + """The overlay must never ship inside an installer -- if it did, the + 'development-only' boundary would be meaningless.""" + builder_config = (REPO_ROOT / "electron-builder.json").read_text() + assert DEV_PORTS_OVERLAY.name not in builder_config, ( + f"{DEV_PORTS_OVERLAY.name} must not be bundled by electron-builder " + f"-- it is a development-only override" + ) + + +def test_dev_overlay_not_referenced_by_production_startup_paths(): + """Neither the Electron main process nor start.sh may reach for the + overlay, or the dev exception would silently become the default path.""" + for path in (REPO_ROOT / "electron" / "main.js", REPO_ROOT / "scripts" / "start.sh"): + assert DEV_PORTS_OVERLAY.name not in path.read_text(), ( + f"{path.name} must not reference the dev-only ports overlay" + ) + + +# ── 5. Dev compose is deliberately unchanged ───────────────────────────── + + +def test_dev_compose_still_publishes_for_local_development(): + """docker-compose.yml is the local development stack (loaded only when + the Electron app is unpackaged). It legitimately publishes datastores + for local tooling, and this workstream deliberately does not change + that -- the finding is about the release/default configuration. This + test pins that intent so the two files' roles stay distinguishable.""" + dev = _load(DEV_COMPOSE) + assert dev["services"]["mysql"].get("ports"), ( + "docker-compose.yml is the development stack and is expected to " + "publish mysql locally -- if this is being changed, the release " + "boundary tests above are the ones that matter, not this file" + ) diff --git a/tests/test_secret_generation.js b/tests/test_secret_generation.js new file mode 100644 index 0000000..59c8220 --- /dev/null +++ b/tests/test_secret_generation.js @@ -0,0 +1,173 @@ +// Unit tests for electron/secrets.js -- the Electron app's provisioning of +// the credentials docker-compose.release.yml now *requires* (see its +// ${VAR:?...} guards and SECURITY-COMPOSE-HARDENING.md). +// +// Run with: node --test tests/test_secret_generation.js +// +// Uses only Node's built-in test runner and assert -- this repo has no JS +// test framework installed, and adding one just for this would be a much +// larger change than the fix warrants. +// +// Deliberately asserts on *properties* of generated secrets (length, +// randomness, difference from the known-weak literals) and never prints a +// generated value. + +const test = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { + SECRET_DEFAULTS, + parseEnvFile, + generateSecrets, +} = require("../electron/secrets.js"); + +function tmpEnvPath() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omnibioai-secrets-")); + return path.join(dir, ".env"); +} + +// Every credential docker-compose.release.yml marks required must be one +// the app actually provisions -- otherwise a fresh install fails to start. +const COMPOSE_REQUIRED = [ + "MYSQL_ROOT_PASSWORD", + "AUTH_SECRET_KEY", + "LICENSE_SECRET", + "GF_ADMIN_PASSWORD", + "LIMSX_DJANGO_SECRET_KEY", + "JUPYTER_TOKEN", + "RSTUDIO_PASSWORD", + "VSCODE_PASSWORD", +]; + +test("provisions every credential the release compose file requires", () => { + for (const key of COMPOSE_REQUIRED) { + assert.ok( + key in SECRET_DEFAULTS, + `${key} is a required var in docker-compose.release.yml but the app ` + + `never generates it -- a fresh install would fail to start` + ); + } +}); + +test("generates secrets into an empty/missing .env", () => { + const envPath = tmpEnvPath(); + const changed = generateSecrets(envPath); + + assert.strictEqual(changed, true, "should report that it wrote secrets"); + assert.ok(fs.existsSync(envPath), ".env should have been created"); + + const env = parseEnvFile(envPath); + for (const key of Object.keys(SECRET_DEFAULTS)) { + assert.ok(env[key], `${key} should have been generated`); + assert.strictEqual( + env[key].length, + 64, + `${key} should be 32 random bytes hex-encoded` + ); + assert.match(env[key], /^[0-9a-f]{64}$/, `${key} should be lowercase hex`); + } +}); + +test("rotates values still set to the known-weak literals", () => { + const envPath = tmpEnvPath(); + // Simulate a .env written by an older Studio version, where every + // credential still holds the public, committed-to-the-repo default. + fs.writeFileSync( + envPath, + Object.entries(SECRET_DEFAULTS) + .map(([k, v]) => `${k}=${v}`) + .join("\n") + "\n" + ); + + const changed = generateSecrets(envPath); + assert.strictEqual(changed, true, "weak defaults must be rotated"); + + const env = parseEnvFile(envPath); + for (const [key, weak] of Object.entries(SECRET_DEFAULTS)) { + assert.notStrictEqual( + env[key], + weak, + `${key} must not still hold its known-weak default` + ); + assert.match(env[key], /^[0-9a-f]{64}$/); + } +}); + +test("preserves already-generated real secrets", () => { + const envPath = tmpEnvPath(); + const first = generateSecrets(envPath); + assert.strictEqual(first, true); + const afterFirst = parseEnvFile(envPath); + + const second = generateSecrets(envPath); + assert.strictEqual( + second, + false, + "a second run must not rotate already-real secrets" + ); + + const afterSecond = parseEnvFile(envPath); + for (const key of Object.keys(SECRET_DEFAULTS)) { + assert.strictEqual( + afterSecond[key], + afterFirst[key], + `${key} must survive a subsequent launch unchanged -- rotating it ` + + `would orphan the data encrypted/signed under the previous value` + ); + } +}); + +test("preserves unrelated keys already in .env", () => { + const envPath = tmpEnvPath(); + fs.writeFileSync(envPath, "ANTHROPIC_API_KEY=user-provided\nDATA_DIR=/data\n"); + + generateSecrets(envPath); + + const env = parseEnvFile(envPath); + assert.strictEqual(env.ANTHROPIC_API_KEY, "user-provided"); + assert.strictEqual(env.DATA_DIR, "/data"); +}); + +test("generates distinct values per key and per install", () => { + const envA = tmpEnvPath(); + const envB = tmpEnvPath(); + generateSecrets(envA); + generateSecrets(envB); + + const a = parseEnvFile(envA); + const b = parseEnvFile(envB); + + // No key reuses another key's value within one install. + const valuesA = Object.keys(SECRET_DEFAULTS).map((k) => a[k]); + assert.strictEqual( + new Set(valuesA).size, + valuesA.length, + "each credential must get its own independent value" + ); + + // No value is shared across two separate installs -- the whole point of + // generating rather than shipping a default. + for (const key of Object.keys(SECRET_DEFAULTS)) { + assert.notStrictEqual( + a[key], + b[key], + `${key} must differ between installations` + ); + } +}); + +test("parseEnvFile handles values containing '='", () => { + const envPath = tmpEnvPath(); + // Base64/fernet-style values routinely contain '=' padding; splitting + // naively on the first '=' and discarding the rest would silently corrupt + // them. + fs.writeFileSync(envPath, "FIELD_ENCRYPTION_KEY=abc==\n"); + assert.strictEqual(parseEnvFile(envPath).FIELD_ENCRYPTION_KEY, "abc=="); +}); + +test("parseEnvFile returns empty object for a missing file", () => { + assert.deepStrictEqual(parseEnvFile(tmpEnvPath()), {}); +}); From fcc763a3c9779d0c084a1200f6423c04c7cb10e4 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 12:04:50 -0500 Subject: [PATCH 4/5] docs(security): document deployment boundary and required secrets New SECURITY-COMPOSE-HARDENING.md covering the original exposure and why it mattered (every application-layer control operates above the datastore, so direct :3306 access bypasses all of it), the production network boundary, the development-only exception and why it can't become the default, gateway-first expectations, and credential requirements. Distinguishes fixed findings from accepted internal communication. Internal Docker-network service-to-service calls are retained in full and listed explicitly as intentional -- including the ones deliberately NOT routed through the gateway: - TES_BASE_URL stays pointed at TES directly; other not-yet-migrated readers in the same image attach no bearer token and would silently 401 through the gateway's AuthMiddleware (tracked under #209) - IAM_URL/POLICY_URL/AUDIT_URL and the gateway's own upstream *_URL values are internal control-plane calls, not client-facing traffic; routing the gateway's upstreams through itself would be circular Records four remaining gaps found but deliberately not addressed here, so they are visible rather than implied fixed: backend services still bind ${HOST_IP:-0.0.0.0} (they do enforce IAM, unlike the datastores, but loopback + nginx-router would suit the design better), pre-existing JWT_SECRET drift between the two release files, weak defaults remaining on non-credential vars like LIMS_PASSWORD/NEO4J_PASSWORD, and the fact that ${VAR:?} proves a value was supplied but not that it is strong. DEPLOYMENT.md's port table now matches reality rather than stating an intent the compose file didn't implement. .env.example and CONTRIBUTING flag which variables are required and the rules for adding new ones. This PR addresses one HIPAA audit finding. It does not complete the audit. Co-Authored-By: Claude Sonnet 5 --- .env.example | 25 +++- CONTRIBUTING.md | 5 + DEPLOYMENT.md | 26 +++- SECURITY-COMPOSE-HARDENING.md | 259 ++++++++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 SECURITY-COMPOSE-HARDENING.md diff --git a/.env.example b/.env.example index 76e55e4..63b2733 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,14 @@ DATA_DIR=/path/to/data DB_INIT_DIR=/path/to/db-init VIDEO_DIR=/path/to/omnibioai-videos/content +# ══════════════════════════════════════════════════════ +# REQUIRED SECRETS — the release compose files have NO +# defaults for these and refuse to start without them. +# The Studio app generates all of them on first launch; +# set them by hand only when running compose directly. +# See SECURITY-COMPOSE-HARDENING.md. +# ══════════════════════════════════════════════════════ + # ── Database ─────────────────────────────────────────── # AUTO-GENERATED on first launch — do not share MYSQL_ROOT_PASSWORD=change-me-in-production @@ -21,6 +29,9 @@ MYSQL_ROOT_PASSWORD=change-me-in-production AUTH_SECRET_KEY=change-me-in-production # AUTO-GENERATED on first launch — do not share LICENSE_SECRET=change-me-in-production +# AUTO-GENERATED on first launch — do not share +# Signs LIMS's own session cookies (distinct from AUTH_SECRET_KEY) +LIMSX_DJANGO_SECRET_KEY=change-me-in-production # ── LIMS ─────────────────────────────────────────────── LIMS_USERNAME=admin @@ -43,10 +54,14 @@ DISCORD_ALERT_WEBHOOK_URL= # ── GitHub (for pulling private images) ──────────────── GHCR_PULL_TOKEN= -# AUTO-GENERATED on first launch — do not share -GF_ADMIN_PASSWORD=omnibioai +# REQUIRED. AUTO-GENERATED on first launch — do not share +GF_ADMIN_PASSWORD=change-me-in-production # ── IDE Services ─────────────────────────────────────── -JUPYTER_TOKEN=omnibioai -RSTUDIO_PASSWORD=omnibioai -VSCODE_PASSWORD=omnibioai +# All REQUIRED. AUTO-GENERATED on first launch — do not share. +# These previously defaulted to the literal "omnibioai" on every +# installation, i.e. every Studio deployment shipped with the same +# publicly-known Jupyter/RStudio/VS Code credentials. +JUPYTER_TOKEN=change-me-in-production +RSTUDIO_PASSWORD=change-me-in-production +VSCODE_PASSWORD=change-me-in-production diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 463d5fa..e1620e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,6 +91,10 @@ omnibioai-studio/ ├── electron-builder.json← electron-builder packaging config ├── docker-compose.yml ← Dev compose file (loaded in dev mode) ├── docker-compose.release.yml ← Packaged app compose file +├── docker-compose.release.dev-ports.yml ← Dev-only overlay: republishes +│ MySQL/Redis locally. Never bundled, never a +│ production default — see SECURITY-COMPOSE-HARDENING.md +├── SECURITY-COMPOSE-HARDENING.md ← Deployment network boundary + required secrets ├── build/ ← App icons (icon.png, .ico, .icns) ├── db-init/ ← SQL init scripts (copied into userData on first run) ├── monitoring/ ← Prometheus + Grafana config @@ -280,6 +284,7 @@ Packaging config is in `electron-builder.json`. The release pipeline (`.github/w - **IPC:** every new `ipcMain.handle` must have a corresponding `contextBridge` exposure — never call `ipcRenderer` directly from renderer code - **Security:** external URLs must be opened with `shell.openExternal` — never `loadURL` an HTTPS URL into the main window - **Paths:** use `app.getPath('userData')` for user data, `process.resourcesPath` for bundled resources — never hardcode absolute paths +- **Secrets/exposure:** never add a `${VAR:-somedefault}` fallback for a credential in a release compose file, and never publish MySQL/Redis there. Local access goes in `docker-compose.release.dev-ports.yml`. New credentials must also be added to `SECRET_DEFAULTS` in `electron/secrets.js` or a fresh install will fail to start — see [SECURITY-COMPOSE-HARDENING.md](SECURITY-COMPOSE-HARDENING.md) --- diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 1f7c38b..50fca1e 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -18,8 +18,8 @@ | Port | Service | Notes | |---|---|---| -| 3306 | MySQL | Internal only — not exposed externally in prod | -| 6380 | Redis | Host-mapped from container 6379 | +| 3306 | MySQL | **Internal only — not published to the host.** Reachable inside the compose network as `mysql:3306`. For local access, layer `docker-compose.release.dev-ports.yml` (see [SECURITY-COMPOSE-HARDENING.md](SECURITY-COMPOSE-HARDENING.md)) | +| 6379 | Redis | **Internal only — not published to the host.** Reachable inside the compose network as `redis:6379`. Same dev-only overlay applies (published as 6380 on the host when it is used) | | 7000 | lims | LIMS Django API | | 7070 | control-center | OmniBioAI Control Center API | | 8000 | workbench | Main Django workbench | @@ -51,12 +51,22 @@ Create `deploy/compose/.env` (never commit this file). ### Required — All Environments +> **The release compose files fail closed on these.** `MYSQL_ROOT_PASSWORD`, +> `AUTH_SECRET_KEY`, `LICENSE_SECRET`, `GF_ADMIN_PASSWORD`, +> `LIMSX_DJANGO_SECRET_KEY`, `JUPYTER_TOKEN`, `RSTUDIO_PASSWORD`, and +> `VSCODE_PASSWORD` have **no defaults** — `docker compose` refuses to start +> without them rather than silently provisioning a guessable credential. See +> [SECURITY-COMPOSE-HARDENING.md](SECURITY-COMPOSE-HARDENING.md). The Studio +> desktop app generates all of them per-installation on first launch; you only +> need to set them by hand when running compose directly. + ```dotenv # ── Database ────────────────────────────────────────────────────────────────── -MYSQL_ROOT_PASSWORD= +MYSQL_ROOT_PASSWORD= # REQUIRED — no default MYSQL_DEFAULT_DB=omnibioai # ── LIMS ────────────────────────────────────────────────────────────────────── +# REQUIRED — no default. Signs LIMS's own session cookies. LIMSX_DJANGO_SECRET_KEY= # LIMSX_DJANGO_DEBUG=False # set to False in production @@ -73,6 +83,7 @@ ANTHROPIC_API_KEY=sk-ant-... OPENAI_API_KEY=sk-... # ── Auth service ────────────────────────────────────────────────────────────── +# REQUIRED — no default. Signs every platform JWT. AUTH_SECRET_KEY= # ── Container registry ──────────────────────────────────────────────────────── @@ -80,8 +91,17 @@ GITHUB_TOKEN= GHCR_PULL_TOKEN= # ── License (unified OMNI-XXXX flow) ─────────────────────────────────────────── +# REQUIRED — no default. LICENSE_SECRET= +# ── Monitoring / interactive services ───────────────────────────────────────── +# All REQUIRED — no defaults. Previously these silently defaulted to the +# literal "omnibioai" on every installation. +GF_ADMIN_PASSWORD= +JUPYTER_TOKEN= +RSTUDIO_PASSWORD= +VSCODE_PASSWORD= + # ── Neo4j (RAG knowledge graph) ───────────────────────────────────────────────── NEO4J_PASSWORD= # defaults to "omnibioai" if unset — override in production diff --git a/SECURITY-COMPOSE-HARDENING.md b/SECURITY-COMPOSE-HARDENING.md new file mode 100644 index 0000000..97807a5 --- /dev/null +++ b/SECURITY-COMPOSE-HARDENING.md @@ -0,0 +1,259 @@ +# Studio Deployment Network Boundary & Credential Requirements + +Scope: the **Studio release/packaged deployment configuration** — the compose +files that ship to and run on customer machines. This document covers one +HIPAA audit finding (infrastructure-level exposure and gateway bypass in the +Studio release compose configuration). It does **not** cover, and makes no +claim about, the rest of the HIPAA audit. + +--- + +## 1. The original exposure + +`docker-compose.release.yml` — the file `electron-builder` bundles into every +packaged installer, and the one the app runs on customer machines — published +both datastores directly to the host with no interface binding: + +```yaml +mysql: + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-omnibioai} + ports: + - "3306:3306" # → 0.0.0.0:3306 + +redis: + ports: + - "6380:6379" # → 0.0.0.0:6380 +``` + +A bare `"3306:3306"` binds `0.0.0.0`, not loopback. On any machine whose host +firewall did not independently block the port, MySQL and Redis were reachable +from the network. + +Three separate problems compounded: + +| # | Problem | Consequence | +|---|---|---| +| 1 | MySQL published on `0.0.0.0:3306` | Direct network reach to the multi-tenant database | +| 2 | Root password defaulted to the literal `omnibioai`, committed in this repo | The reachable database had a **publicly known** credential | +| 3 | Redis published on `0.0.0.0:6380`, no auth at all | Session/cache/audit-stream data readable and writable by anyone | + +### Why this mattered more than "a database port is open" + +Every application-layer control the platform has — IAM authentication, RBAC +permission checks, organization isolation, audit logging, the API Gateway's +zero-trust middleware — operates *above* the datastore. A client that can +speak the MySQL wire protocol to port 3306 is beneath all of it. It does not +present a JWT, is not subject to `organization_id` scoping, is not filtered +by any queryset, and generates no audit event. + +The extensive tenant-isolation work already merged across `omnibioai-auth`, +`omnibioai-lims`, `omnibioai-model-registry`, and others enforces its +boundaries in application code against this shared database. A direct +connection reads every organization's rows regardless. Item 2 meant no +credential guessing was required. + +`DEPLOYMENT.md`'s own port table already documented the intended design: + +> | 3306 | MySQL | Internal only — not exposed externally in prod | + +The intent was right. The compose file simply never implemented it. + +--- + +## 2. The production network boundary + +Release/default configuration (`docker-compose.release.yml`, and the +dash-named `docker-compose-release.yml` kept in parity with it): + +- **MySQL: not published.** No `ports:` entry. +- **Redis: not published.** No `ports:` entry. +- Both remain fully reachable **inside** the compose network by service name + (`mysql:3306`, `redis:6379`), which is how every consumer already addressed + them. No connection string changed. +- The **API Gateway (`:8080`) remains published** — it is the intended + entry point, and a regression test asserts it stays that way, so the + exposure fix cannot be over-applied to the service that is supposed to be + externally reachable. + +The distinction this enforces: + +| Category | Status | Example | +|---|---|---| +| Internal Docker-network service-to-service | **Allowed, unchanged** | `workbench → mysql:3306` | +| Externally published datastore | **Removed** | `0.0.0.0:3306` | +| Externally published gateway | **Retained deliberately** | `api-gateway:8080` | +| Browser/client-facing API | Through gateway / nginx-router | `/_svc/*` | + +--- + +## 3. The development-only exception + +Local access to the release stack's datastores is preserved — through an +explicit, separate file, never by weakening the default: + +```bash +docker compose -f docker-compose.release.yml \ + -f docker-compose.release.dev-ports.yml \ + --env-file .env up -d +``` + +`docker-compose.release.dev-ports.yml` republishes both, and: + +- binds **`127.0.0.1` by default**, not `0.0.0.0` — enabling local access does + not expose a datastore to the developer's LAN. Overridable via + `MYSQL_DEV_HOST_IP` / `REDIS_DEV_HOST_IP` for the rare case that needs it; +- is **not bundled** by `electron-builder` (asserted by test); +- is **not referenced** by `electron/main.js` or `scripts/start.sh` — the two + production startup paths (asserted by test). + +The dev exception therefore cannot become the production default by +accident: it requires someone to type a second `-f` flag. + +`docker-compose.yml` (the local development stack, used only when the Electron +app is unpackaged) is **deliberately unchanged** and still publishes ports for +local tooling. That is its purpose; the finding is about the release +configuration. A test pins this distinction so the two files' roles stay +legible. + +--- + +## 4. Credential requirements + +Every credential below previously fell back to a public literal committed to +this repository. All are now **required** — compose refuses to start without +them, via `${VAR:?message}`: + +| Variable | Was silently defaulting to | +|---|---| +| `MYSQL_ROOT_PASSWORD` | `omnibioai` | +| `AUTH_SECRET_KEY` | `change-me-in-production` | +| `LICENSE_SECRET` | `omnibioai-secret-change-in-production` | +| `GF_ADMIN_PASSWORD` | `omnibioai` | +| `LIMSX_DJANGO_SECRET_KEY` | `omnibioai-studio-secret` | +| `JUPYTER_TOKEN` | `omnibioai` | +| `RSTUDIO_PASSWORD` | `omnibioai` | +| `VSCODE_PASSWORD` | `omnibioai` | + +Missing any of them now produces: + +``` +error while interpolating services..environment.: +required variable MYSQL_ROOT_PASSWORD is missing a value: MYSQL_ROOT_PASSWORD must be set +``` + +…instead of a stack that starts successfully with a guessable password. + +**No replacement secrets are hard-coded.** Values come from the environment +only. The Studio app generates them per-installation on first launch +(`electron/secrets.js`, 32 random bytes each, rotated if still at a known-weak +literal), and never logs a value. `tests/test_secret_generation.js` asserts on +properties (length, charset, distinctness across installs) and likewise never +prints one. + +Two related fixes fell out of this: + +- **`LIMSX_DJANGO_SECRET_KEY`, `JUPYTER_TOKEN`, `RSTUDIO_PASSWORD`, and + `VSCODE_PASSWORD` were never in the app's generation list at all** — so + every Studio installation everywhere shared the same four hardcoded values, + including the key signing LIMS's session cookies. They are generated now. +- **`scripts/start.sh` defaulted `MYSQL_ROOT_PASSWORD` to `omnibioai`** in its + "no .env found" branch, which would have re-supplied the weak value and + defeated the `:?` guard. Removed, so the guard actually fires. + +`ADMIN_KEY` was **removed** rather than made required: PR11 replaced the +license server's static shared-secret check with IAM authorization, and +`backend/license_server.py` has had zero references to it since. Both release +files were still passing a dead `${ADMIN_KEY:-admin-secret}`. + +--- + +## 5. Gateway-first access + +Each backend dependency was assessed individually rather than rewritten +wholesale — most internal Docker URLs are legitimate and were left alone. + +**Fixed:** `celery-worker` had no `GATEWAY_URL` in either release file, though +`docker-compose.yml` gained it with issue #196. The call sites that #196 +repointed read `GATEWAY_URL` exclusively, so **the packaged release build +never received that fix** — only the dev stack did. Now wired in both. + +**Deliberately left pointing directly at the backend:** + +- `TES_BASE_URL` in `workbench`/`celery-worker`. Other, not-yet-migrated + readers of this variable in the same image (`bioquery_tasks.py`, #209) + attach no bearer token and would silently 401 if it were routed through the + gateway's `AuthMiddleware`. This matches the reasoning already recorded in + `docker-compose.yml` and is tracked under #209, not this workstream. +- `IAM_URL`, `POLICY_URL`, `AUDIT_URL`, `TOOLSERVER_BASE_URL`, + `MODEL_REGISTRY_BASE_URL`, `RAG_BASE_URL`, `BILLING_URL`, `LIMS_BASE_URL`, + and the gateway's own upstream `*_URL` values. These are **internal control- + plane and service-mesh calls on the private Docker network**, not + client-facing traffic. The gateway routes *external* clients to these + services; routing the gateway's own upstreams through itself would be + circular. Publishing them is a separate question from how they are + addressed internally. + +--- + +## 6. Accepted / intentionally retained + +Recorded explicitly so they are visible decisions rather than oversights: + +- **Internal Docker-network service-to-service communication is retained in + full.** It is not the finding, and severing it would break the platform. +- **`docker-compose.yml` (dev stack) still publishes datastores.** By design. +- **Other services remain published** (`workbench:8000`, `tes:8081`, + `auth-service:8001`, etc.). They enforce their own IAM authorization and are + out of scope for this datastore-exposure finding — but see the gap below. +- **`docker-compose-release.yml` (dash) is not the shipped artifact.** + `electron-builder.json` and `electron/main.js` both use the dot-named + `docker-compose.release.yml`. The dash file is kept in credential/exposure + parity — the two have silently diverged before, which is why + `tests/test_compose_release_config.py` exists — but consolidating or + deleting it is separate work. + +--- + +## 7. Remaining gaps (not addressed here) + +Found during this work; **not fixed in this change** and not claimed to be: + +1. **Backend services are published on `${HOST_IP:-0.0.0.0}`.** The default + binds all interfaces for `workbench`, `tes`, `auth-service`, `rag`, + `model-registry`, and others. These do enforce IAM authorization, so this + is materially different from an unauthenticated datastore — but the + gateway-first design would be better served by binding them to loopback + and routing through `nginx-router`, as `lims`, `control-center`, and + `nginx-router` itself already do. Larger change; needs its own assessment. +2. **`docker-compose-release.yml` lacks `security-audit`'s `JWT_SECRET` + wiring** that the dot file has. Pre-existing drift between the two files, + unrelated to exposure; left alone to keep this change scoped. +3. **Weak defaults remain on non-credential variables**, e.g. + `LIMS_PASSWORD: ${LIMS_PASSWORD:-omnibioai}` (a service-account password + for workbench→LIMS) and `NEO4J_PASSWORD`. These sit on a different path + than the datastore exposure fixed here and warrant their own pass. +4. **No verification that a real deployment has rotated its credentials.** + The `:?` guard proves a value was *supplied*, not that it is strong or + unique. A startup-time weak-credential check would close that. + +--- + +## 8. Verifying the boundary + +```bash +# No mysql/redis ports in the release default +docker compose -f docker-compose.release.yml config | grep -A2 -E '^ (mysql|redis):' + +# Fails closed without required secrets +docker compose --env-file /dev/null -f docker-compose.release.yml config + +# Regression suites +python3 -m pytest tests/test_compose_network_exposure.py \ + tests/test_compose_release_config.py -q +npm run test:secrets +``` + +CI enforces all of the above on every push and PR, including a negative test +asserting that both release files *refuse* to validate when required secrets +are absent. From 14c5b490944207674a75a514a39fc5a94479976c Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 16:13:58 -0500 Subject: [PATCH 5/5] docs(security): correct compose hardening documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two factual corrections found during PR #44 validation, no code or compose behavior changed: - SECURITY-COMPOSE-HARDENING.md §7 gap #2 claimed docker-compose-release.yml was missing security-audit's JWT_SECRET wiring. Verified against origin/main and both current release files: JWT_SECRET is identical in both. The actual pre-existing gap (present on origin/main before this PR, untouched by it) is that docker-compose-release.yml's security-audit block lacks AUDIT_DATABASE_URL and the depends_on: mysql: condition: service_healthy entry that docker-compose.release.yml has. - README.md's Services table described MySQL/Redis as always host-mapped (:3306, "mapped :6380 on host"), which was never updated for the release default this PR closed. Now distinguishes production/release (internal only, not published) from development (still published directly, or via the explicit loopback-bound docker-compose.release.dev-ports.yml overlay against the release stack). Co-Authored-By: Claude Sonnet 5 --- README.md | 21 +++++++++++++++++++-- SECURITY-COMPOSE-HARDENING.md | 13 ++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2817272..fb2f61c 100644 --- a/README.md +++ b/README.md @@ -259,8 +259,25 @@ regardless of whether the server call itself succeeded. ### Data Layer | Service | Port | Image | |---------|------|-------| -| MySQL | :3306 | mysql:8.0 | -| Redis | :6379 (mapped :6380 on host) | redis:7-alpine | +| MySQL | :3306 (internal only in production/release — see below) | mysql:8.0 | +| Redis | :6379 (internal only in production/release — see below) | redis:7-alpine | + +**Production/release** (`docker-compose.release.yml`, the config packaged +into the Electron app): MySQL and Redis are **not published to the host** — +reachable only inside the Compose network, as `mysql:3306` / `redis:6379`. +Every other service still addresses them exactly that way. + +**Development**: the local dev stack (`docker-compose.yml`) still publishes +both directly (`:3306` / `:6380`) for convenience, as it always has. To get +the same local access against the release stack instead, layer the explicit +`docker-compose.release.dev-ports.yml` overlay: +```bash +docker compose -f docker-compose.release.yml -f docker-compose.release.dev-ports.yml up -d +``` +This overlay binds to `127.0.0.1` only, not `0.0.0.0`, and is never bundled +into the packaged app or referenced by its startup path — it has to be +opted into explicitly. See [SECURITY-COMPOSE-HARDENING.md](SECURITY-COMPOSE-HARDENING.md) +for the full rationale. ### Security Control Plane | Service | Port | Image | diff --git a/SECURITY-COMPOSE-HARDENING.md b/SECURITY-COMPOSE-HARDENING.md index 97807a5..1622c28 100644 --- a/SECURITY-COMPOSE-HARDENING.md +++ b/SECURITY-COMPOSE-HARDENING.md @@ -226,9 +226,16 @@ Found during this work; **not fixed in this change** and not claimed to be: gateway-first design would be better served by binding them to loopback and routing through `nginx-router`, as `lims`, `control-center`, and `nginx-router` itself already do. Larger change; needs its own assessment. -2. **`docker-compose-release.yml` lacks `security-audit`'s `JWT_SECRET` - wiring** that the dot file has. Pre-existing drift between the two files, - unrelated to exposure; left alone to keep this change scoped. +2. **`docker-compose-release.yml`'s `security-audit` block lacks + `AUDIT_DATABASE_URL` and the corresponding `depends_on: mysql: condition: + service_healthy`** that `docker-compose.release.yml` has (both files wire + `JWT_SECRET` identically — that part is not the gap). Without + `AUDIT_DATABASE_URL`, `GET /audit/events` falls back to the app's own + hardcoded `mysql+pymysql://root:root@localhost:3306/omnibioai_audit` + default, which is unreachable from inside the container. Pre-existing + drift between the two files (confirmed present on `origin/main` before + this change), unrelated to exposure; left alone to keep this change + scoped. 3. **Weak defaults remain on non-credential variables**, e.g. `LIMS_PASSWORD: ${LIMS_PASSWORD:-omnibioai}` (a service-account password for workbench→LIMS) and `NEO4J_PASSWORD`. These sit on a different path