diff --git a/.env.example b/.env.example index 76e55e42..63b2733a 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a201c3c..3a9820d0 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/CONTRIBUTING.md b/CONTRIBUTING.md index 463d5fa8..e1620e72 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 1f7c38b2..50fca1e4 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/README.md b/README.md index 28172726..fb2f61cf 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 new file mode 100644 index 00000000..1622c289 --- /dev/null +++ b/SECURITY-COMPOSE-HARDENING.md @@ -0,0 +1,266 @@ +# 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`'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 + 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. diff --git a/docker-compose-release.yml b/docker-compose-release.yml index dd467150..d52235b7 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 00000000..e247d311 --- /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 05284652..945e406e 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 diff --git a/electron/main.js b/electron/main.js index 78db7bd0..5347d142 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 00000000..385c25b3 --- /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/package.json b/package.json index e9cf10b5..957516dd 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/scripts/start.sh b/scripts/start.sh index 8fea788d..525bd719 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 c11628e5..da134dd7 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 }) => (
diff --git a/tests/test_compose_network_exposure.py b/tests/test_compose_network_exposure.py new file mode 100644 index 00000000..22c36631 --- /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 00000000..59c82209 --- /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()), {}); +});