From 2ea3343c9f48bd36a067fa1a838cf725ca46760d Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 7 Apr 2026 14:54:13 +0200 Subject: [PATCH 1/2] docs(security): add infra hardening plans --- ...26-04-07-sec-infra-01-lavalink-password.md | 158 +++++++++++ .../2026-04-07-sec-infra-02-docker-secrets.md | 266 ++++++++++++++++++ .../2026-04-07-sec-infra-03-backup-pgpass.md | 134 +++++++++ ...6-04-07-sec-infra-04-dockerfile-nonroot.md | 101 +++++++ ...4-07-sec-infra-05-dev-postgres-password.md | 95 +++++++ ...-sec-infra-06-bot-sync-secret-fail-fast.md | 128 +++++++++ ...-04-07-sec-infra-07-vite-bind-localhost.md | 118 ++++++++ ...04-07-sec-infra-08-ci-security-scanning.md | 184 ++++++++++++ ...04-07-sec-infra-09-env-example-lavalink.md | 80 ++++++ ...-04-07-sec-infra-10-pgadmin-credentials.md | 99 +++++++ ...-07-sec-infra-11-postgres-port-exposure.md | 95 +++++++ ...-07-sec-infra-12-dockerignore-env-guard.md | 131 +++++++++ 12 files changed, 1589 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-01-lavalink-password.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-02-docker-secrets.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-03-backup-pgpass.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-04-dockerfile-nonroot.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-05-dev-postgres-password.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-06-bot-sync-secret-fail-fast.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-07-vite-bind-localhost.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-08-ci-security-scanning.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-09-env-example-lavalink.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-10-pgadmin-credentials.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-11-postgres-port-exposure.md create mode 100644 docs/superpowers/plans/2026-04-07-sec-infra-12-dockerignore-env-guard.md diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-01-lavalink-password.md b/docs/superpowers/plans/2026-04-07-sec-infra-01-lavalink-password.md new file mode 100644 index 0000000..ceecafa --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-01-lavalink-password.md @@ -0,0 +1,158 @@ +# Lavalink Hardcoded Password — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** CRITICAL +**Goal:** Eliminate the hardcoded `youshallnotpass` Lavalink password from every committed file. Require `LAVALINK_PASSWORD` to be supplied via environment, both for the Lavalink server config and for all healthchecks/clients that authenticate against it. +**Architecture:** Lavalink reads `application.yml` directly. Spring Boot config supports `${ENV_VAR}` substitution natively, so the YAML can reference an env var the container sees. Healthchecks use `wget --header=Authorization` and must read the same env var. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`youshallnotpass` is the well-known default Lavalink password and is currently hardcoded in: + +- `lavalink/application.yml:25` — `password: "youshallnotpass"` +- `docker-compose.yml:32` — healthcheck header `Authorization: youshallnotpass` +- `docker-compose.yml:122` — preview-lavalink healthcheck same string +- `docker-compose.prod.yml:32` — `LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD:-youshallnotpass}` (default falls back) +- `docker-compose.prod.yml:34` — healthcheck header `Authorization: youshallnotpass` +- `.env.example:47` — `LAVALINK_PASSWORD=youshallnotpass` +- `packages/config/src/index.ts:59` — `process.env.LAVALINK_PASSWORD || "youshallnotpass"` + +Anyone running FluxCore who forgets to override the password is exposing an unauthenticated audio gateway. Even when overridden in dev, the healthchecks still use the literal string and would fail silently — meaning real deployments must currently keep the default. + +Note: `lavalink/application.yml:17` also contains a real-looking YouTube OAuth refresh token that should be rotated separately; this plan does not cover that, but the implementer MUST flag it during PR review. + +## Files + +- `lavalink/application.yml` +- `docker-compose.yml` +- `docker-compose.prod.yml` +- `.env.example` +- `packages/config/src/index.ts` + +## Tasks + +### Task 1: Make `application.yml` read the password from the environment + +- [ ] **Step 1: Write verification check.** Confirm the literal string is present today: + ```bash + grep -n 'youshallnotpass' lavalink/application.yml + ``` +- [ ] **Step 2: Run** — expect line 25 to print ` password: "youshallnotpass"`. +- [ ] **Step 3: Apply fix.** Replace line 25 with a Spring Boot env-var reference (no default — Spring will fail to start if missing): + ```yaml + - password: "youshallnotpass" + + password: ${LAVALINK_SERVER_PASSWORD} + ``` +- [ ] **Step 4: Verify.** `grep -n 'youshallnotpass' lavalink/application.yml` must print nothing, and `grep -n 'LAVALINK_SERVER_PASSWORD' lavalink/application.yml` must show line 25. +- [ ] **Step 5: Commit.** `fix(lavalink): require LAVALINK_SERVER_PASSWORD env var in application.yml` + +### Task 2: Pass the password to the dev Lavalink container and parameterise its healthcheck + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n 'youshallnotpass' docker-compose.yml + ``` +- [ ] **Step 2: Run** — expect lines 32 and 122 to print the literal. +- [ ] **Step 3: Apply fix.** Edit `docker-compose.yml` Lavalink service (lines 25–41) and preview-lavalink (115–130): + ```yaml + lavalink: + image: ghcr.io/lavalink-devs/lavalink:4 + volumes: + - ./lavalink/application.yml:/opt/Lavalink/application.yml:ro + environment: + - _JAVA_OPTIONS=-Xmx128m + + - LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD:?LAVALINK_PASSWORD is required} + healthcheck: + - test: ["CMD-SHELL", "wget -qO- --header='Authorization: youshallnotpass' http://localhost:2333/version || exit 1"] + + test: ["CMD-SHELL", "wget -qO- --header=\"Authorization: ${LAVALINK_SERVER_PASSWORD}\" http://localhost:2333/version || exit 1"] + ``` + Apply the identical change to the `preview-lavalink` block. +- [ ] **Step 4: Verify.** + ```bash + grep -n 'youshallnotpass' docker-compose.yml # must be empty + LAVALINK_PASSWORD=test-pw docker compose -f docker-compose.yml config | grep -A2 lavalink | grep LAVALINK_SERVER_PASSWORD + ``` + Second command must show `LAVALINK_SERVER_PASSWORD: test-pw`. +- [ ] **Step 5: Commit.** `fix(docker): require LAVALINK_PASSWORD for dev/preview lavalink and parameterise healthcheck` + +### Task 3: Remove the production fallback default and parameterise its healthcheck + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n 'youshallnotpass' docker-compose.prod.yml + ``` +- [ ] **Step 2: Run** — expect lines 32 and 34 to print. +- [ ] **Step 3: Apply fix.** Edit `docker-compose.prod.yml` lavalink block (lines 26–47): + ```yaml + environment: + - _JAVA_OPTIONS=-Xmx256m + - - LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD:-youshallnotpass} + + - LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD:?LAVALINK_PASSWORD is required} + healthcheck: + - test: ["CMD-SHELL", "wget -qO- --header='Authorization: youshallnotpass' http://localhost:2333/version || exit 1"] + + test: ["CMD-SHELL", "wget -qO- --header=\"Authorization: ${LAVALINK_SERVER_PASSWORD}\" http://localhost:2333/version || exit 1"] + ``` +- [ ] **Step 4: Verify.** + ```bash + grep -n 'youshallnotpass' docker-compose.prod.yml # must be empty + unset LAVALINK_PASSWORD; docker compose -f docker-compose.prod.yml config 2>&1 | grep -i 'LAVALINK_PASSWORD is required' + ``` + Second command must print the error (proves the `:?` guard fires when missing). +- [ ] **Step 5: Commit.** `fix(docker): drop default lavalink password in prod compose` + +### Task 4: Remove the hardcoded fallback in `packages/config` + +- [ ] **Step 1: Write verification check.** Add a Vitest case to `packages/config/tests/index.test.ts` (create file if missing): + ```typescript + import { describe, it, expect, beforeEach, vi } from "vitest"; + + describe("lavalink password", () => { + beforeEach(() => { + vi.resetModules(); + process.env.DISCORD_TOKEN = "x"; + process.env.CLIENT_ID = "y"; + }); + + it("throws when LAVALINK_PASSWORD is unset", async () => { + delete process.env.LAVALINK_PASSWORD; + await expect(import("../src/index")).rejects.toThrow(/LAVALINK_PASSWORD/); + }); + + it("uses the env value when present", async () => { + process.env.LAVALINK_PASSWORD = "from-env"; + const { config } = await import("../src/index"); + expect(config.lavalinkPassword).toBe("from-env"); + }); + }); + ``` +- [ ] **Step 2: Run** `pnpm --filter @fluxcore/config test` — expect failure (current code falls back to `youshallnotpass`). +- [ ] **Step 3: Apply fix.** Edit `packages/config/src/index.ts:59`: + ```typescript + - const lavalinkPassword = process.env.LAVALINK_PASSWORD || "youshallnotpass"; + + const lavalinkPassword = process.env.LAVALINK_PASSWORD; + + if (!lavalinkPassword) { + + throw new Error("Missing required environment variable: LAVALINK_PASSWORD"); + + } + ``` +- [ ] **Step 4: Verify.** `pnpm --filter @fluxcore/config test` passes; `pnpm typecheck` passes. +- [ ] **Step 5: Commit.** `fix(config): require LAVALINK_PASSWORD instead of falling back to default` + +### Task 5: End-to-end smoke test + +- [ ] **Step 1: Write verification check.** Spin up the bot profile with a real password: + ```bash + LAVALINK_PASSWORD=$(openssl rand -base64 32) docker compose --profile bot up -d lavalink + docker compose ps lavalink + docker compose logs lavalink | tail -50 + ``` +- [ ] **Step 2: Run** — confirm container reports `healthy` within 60s and logs show `Started Lavalink` with no `youshallnotpass` reference. +- [ ] **Step 3: Apply fix.** (No-op — verification only.) +- [ ] **Step 4: Verify.** `docker compose --profile bot down`. Then run with the env var unset: + ```bash + unset LAVALINK_PASSWORD; docker compose --profile bot config 2>&1 | grep -i required + ``` + Must print the `LAVALINK_PASSWORD is required` error from the `:?` guard. +- [ ] **Step 5: Commit.** No commit needed if Tasks 1–4 are clean. Otherwise create `fix(lavalink): smoke-test follow-up` with any tweaks. diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-02-docker-secrets.md b/docs/superpowers/plans/2026-04-07-sec-infra-02-docker-secrets.md new file mode 100644 index 0000000..357d85d --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-02-docker-secrets.md @@ -0,0 +1,266 @@ +# Production Secrets via env_file → Docker Secrets — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** HIGH +**Goal:** Stop loading the entire `.env.prod` file as plaintext environment variables in production. Migrate every sensitive value to Docker secrets and have each app read them via `*_FILE` environment variables. +**Architecture:** docker-compose `secrets:` top-level block sources files from `./secrets/*` (gitignored). Each service references the secrets it needs and reads them at startup. Node services already use `dotenv` so we add a tiny secret-loader that reads `*_FILE` paths and exports the underlying value into `process.env` before `loadConfig()` runs. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`docker-compose.prod.yml` currently uses `env_file: - .env.prod` for `bot` (line 8) and `dashboard` (line 55), and inlines `${POSTGRES_PASSWORD}` for postgres (line 76) and the bot's `DATABASE_URL` (line 7). Consequences: + +- The whole `.env.prod` is mounted into the container's environment, exposing every secret to anything that can `cat /proc//environ` (other containers if namespaces leak, monitoring agents, crash dumps). +- `docker inspect` reveals the values in plaintext to anyone with Docker socket access. +- Backups (see Finding 3) and CI logs frequently leak entire env blocks. + +Docker secrets store the values in a tmpfs-mounted file (`/run/secrets/`) readable only by the process inside the container, never appearing in `docker inspect` or `ps eww`. + +## Files + +- `docker-compose.prod.yml` +- `packages/config/src/index.ts` (new helper) +- `.env.example` +- `.gitignore` +- `secrets/.gitkeep` (new) + +## Tasks + +### Task 1: Add a `_FILE` env-var resolver to `packages/config` + +- [ ] **Step 1: Write verification test.** Add to `packages/config/tests/secret-files.test.ts`: + ```typescript + import { describe, it, expect, beforeEach } from "vitest"; + import { writeFileSync, mkdtempSync } from "node:fs"; + import { join } from "node:path"; + import { tmpdir } from "node:os"; + import { resolveSecretFiles } from "../src/secret-files"; + + describe("resolveSecretFiles", () => { + beforeEach(() => { + delete process.env.DISCORD_TOKEN; + delete process.env.DISCORD_TOKEN_FILE; + }); + + it("loads value from a *_FILE path into the base var", () => { + const dir = mkdtempSync(join(tmpdir(), "sec-")); + const path = join(dir, "token"); + writeFileSync(path, "abc123\n"); + process.env.DISCORD_TOKEN_FILE = path; + resolveSecretFiles(["DISCORD_TOKEN"]); + expect(process.env.DISCORD_TOKEN).toBe("abc123"); + }); + + it("leaves base var alone if no *_FILE present", () => { + process.env.DISCORD_TOKEN = "literal"; + resolveSecretFiles(["DISCORD_TOKEN"]); + expect(process.env.DISCORD_TOKEN).toBe("literal"); + }); + + it("throws when both are set", () => { + process.env.DISCORD_TOKEN = "literal"; + process.env.DISCORD_TOKEN_FILE = "/nope"; + expect(() => resolveSecretFiles(["DISCORD_TOKEN"])).toThrow(/both/); + }); + }); + ``` +- [ ] **Step 2: Run** `pnpm --filter @fluxcore/config test` — expect failure (helper missing). +- [ ] **Step 3: Apply fix.** Create `packages/config/src/secret-files.ts`: + ```typescript + import { readFileSync } from "node:fs"; + + export function resolveSecretFiles(names: readonly string[]): void { + for (const name of names) { + const fileVar = `${name}_FILE`; + const filePath = process.env[fileVar]; + const literal = process.env[name]; + if (filePath && literal) { + throw new Error( + `Both ${name} and ${fileVar} are set; choose one.`, + ); + } + if (filePath) { + process.env[name] = readFileSync(filePath, "utf8").trimEnd(); + } + } + } + ``` + Then in `packages/config/src/index.ts` add at the top of `loadConfig()`: + ```typescript + import { resolveSecretFiles } from "./secret-files"; + + function loadConfig(): Config { + resolveSecretFiles([ + "DISCORD_TOKEN", + "DASHBOARD_CLIENT_SECRET", + "DASHBOARD_SESSION_SECRET", + "BOT_SYNC_SECRET", + "LAVALINK_PASSWORD", + "POSTGRES_PASSWORD", + "DATABASE_URL", + ]); + // ...rest unchanged + ``` + Re-export from `packages/config/src/index.ts`: `export { resolveSecretFiles } from "./secret-files";` +- [ ] **Step 4: Verify.** `pnpm --filter @fluxcore/config test` passes; `pnpm typecheck` passes. +- [ ] **Step 5: Commit.** `feat(config): add resolveSecretFiles helper for Docker secret _FILE pattern` + +### Task 2: Define secrets in `docker-compose.prod.yml` + +- [ ] **Step 1: Write verification check.** `docker compose -f docker-compose.prod.yml config` should currently show `env_file: .env.prod` under bot/dashboard. Capture baseline: + ```bash + docker compose -f docker-compose.prod.yml config | grep -E '(env_file|secrets:)' + ``` +- [ ] **Step 2: Run** — expect only `env_file` lines, no `secrets:`. +- [ ] **Step 3: Apply fix.** Rewrite `docker-compose.prod.yml` to use secrets. Append at the end of the file: + ```yaml + secrets: + discord_token: + file: ./secrets/discord_token + dashboard_client_secret: + file: ./secrets/dashboard_client_secret + dashboard_session_secret: + file: ./secrets/dashboard_session_secret + bot_sync_secret: + file: ./secrets/bot_sync_secret + lavalink_password: + file: ./secrets/lavalink_password + postgres_password: + file: ./secrets/postgres_password + ``` + Update the bot service (replace lines 2–24): + ```yaml + bot: + build: + context: . + target: production-bot + environment: + NODE_ENV: production + DISCORD_TOKEN_FILE: /run/secrets/discord_token + DASHBOARD_SESSION_SECRET_FILE: /run/secrets/dashboard_session_secret + BOT_SYNC_SECRET_FILE: /run/secrets/bot_sync_secret + LAVALINK_PASSWORD_FILE: /run/secrets/lavalink_password + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + # DATABASE_URL is built at runtime from POSTGRES_PASSWORD inside the entrypoint + DATABASE_URL_TEMPLATE: "postgresql://fluxcore:__PW__@postgres:5432/fluxcore" + CLIENT_ID: ${CLIENT_ID:?CLIENT_ID required} + LAVALINK_HOST: lavalink + LAVALINK_PORT: "2333" + secrets: + - discord_token + - dashboard_session_secret + - bot_sync_secret + - lavalink_password + - postgres_password + depends_on: + postgres: + condition: service_healthy + lavalink: + condition: service_healthy + networks: + - backend + restart: always + deploy: + resources: + limits: + memory: 512M + profiles: + - bot + - full + ``` + Update the dashboard service similarly (replace lines 49–70) with secrets `discord_token`, `dashboard_client_secret`, `dashboard_session_secret`, `bot_sync_secret`, `postgres_password`. Update postgres (lines 72–91): + ```yaml + postgres: + image: postgres:18-alpine + environment: + POSTGRES_USER: fluxcore + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + POSTGRES_DB: fluxcore + secrets: + - postgres_password + # ...rest unchanged + ``` + Lavalink also needs the password as a file; either keep using `LAVALINK_PASSWORD` env (it goes into the JVM's `${LAVALINK_SERVER_PASSWORD}`) or shell-substitute via an entrypoint wrapper. Simplest: keep one env var read from the secret file in an inline command: + ```yaml + lavalink: + image: ghcr.io/lavalink-devs/lavalink:4 + volumes: + - ./lavalink/application.yml:/opt/Lavalink/application.yml:ro + environment: + - _JAVA_OPTIONS=-Xmx256m + secrets: + - lavalink_password + entrypoint: + - sh + - -c + - 'export LAVALINK_SERVER_PASSWORD=$(cat /run/secrets/lavalink_password) && exec /opt/Lavalink/launch.sh' + healthcheck: + test: ["CMD-SHELL", "wget -qO- --header=\"Authorization: $(cat /run/secrets/lavalink_password)\" http://localhost:2333/version || exit 1"] + ``` +- [ ] **Step 4: Verify.** + ```bash + mkdir -p secrets + for f in discord_token dashboard_client_secret dashboard_session_secret bot_sync_secret lavalink_password postgres_password; do + openssl rand -base64 32 > "secrets/$f" + done + CLIENT_ID=test docker compose -f docker-compose.prod.yml config > /tmp/prodcfg.yml + grep -c '_FILE:' /tmp/prodcfg.yml # expect >= 8 + grep -c 'env_file' /tmp/prodcfg.yml # expect 0 + grep 'secrets:' /tmp/prodcfg.yml # expect top-level secrets block + ``` +- [ ] **Step 5: Commit.** `fix(docker): migrate prod secrets from env_file to Docker secrets` + +### Task 3: Build the runtime `DATABASE_URL` from the postgres password file + +- [ ] **Step 1: Write verification check.** Add an integration smoke test as a shell script `scripts/test-db-url-from-secret.sh`: + ```bash + #!/usr/bin/env bash + set -euo pipefail + TMPDIR=$(mktemp -d) + echo "secret-pw" > "$TMPDIR/pw" + POSTGRES_PASSWORD_FILE="$TMPDIR/pw" \ + node -e ' + const { resolveSecretFiles } = require("./packages/config/dist/secret-files.js"); + resolveSecretFiles(["POSTGRES_PASSWORD"]); + process.env.DATABASE_URL = `postgresql://fluxcore:${process.env.POSTGRES_PASSWORD}@postgres:5432/fluxcore`; + console.log(process.env.DATABASE_URL); + ' + ``` +- [ ] **Step 2: Run** — expect `postgresql://fluxcore:secret-pw@postgres:5432/fluxcore`. +- [ ] **Step 3: Apply fix.** Update `packages/config/src/index.ts` to construct `DATABASE_URL` if it's unset but `POSTGRES_PASSWORD` is set: + ```typescript + if (!process.env.DATABASE_URL && process.env.POSTGRES_PASSWORD) { + const host = process.env.POSTGRES_HOST || "postgres"; + const db = process.env.POSTGRES_DB || "fluxcore"; + const user = process.env.POSTGRES_USER || "fluxcore"; + process.env.DATABASE_URL = `postgresql://${user}:${process.env.POSTGRES_PASSWORD}@${host}:5432/${db}`; + } + ``` +- [ ] **Step 4: Verify.** `pnpm --filter @fluxcore/config test && pnpm --filter @fluxcore/config build` and re-run the script. +- [ ] **Step 5: Commit.** `feat(config): build DATABASE_URL from POSTGRES_PASSWORD when unset` + +### Task 4: Document the migration in `.env.example` and add `secrets/` to gitignore + +- [ ] **Step 1: Write verification check.** `grep -n '^secrets/' .gitignore` — expect no match yet. +- [ ] **Step 2: Run** above command. +- [ ] **Step 3: Apply fix.** Append to `.gitignore`: + ``` + /secrets/* + !/secrets/.gitkeep + ``` + Create `secrets/.gitkeep` (empty file). Add a section to `.env.example`: + ``` + # === Production secrets (Docker secrets — DO NOT commit values) === + # In production, place each secret as a file under ./secrets/: + # secrets/discord_token + # secrets/dashboard_client_secret + # secrets/dashboard_session_secret + # secrets/bot_sync_secret + # secrets/lavalink_password + # secrets/postgres_password + # Generate strong values with: openssl rand -base64 32 + ``` +- [ ] **Step 4: Verify.** `grep -n 'Docker secrets' .env.example` and `git check-ignore secrets/discord_token` (must report ignored). +- [ ] **Step 5: Commit.** `docs(env): document Docker secrets layout for production` diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-03-backup-pgpass.md b/docs/superpowers/plans/2026-04-07-sec-infra-03-backup-pgpass.md new file mode 100644 index 0000000..d63dd82 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-03-backup-pgpass.md @@ -0,0 +1,134 @@ +# Backup Service `PGPASSWORD` Exposure — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** HIGH +**Goal:** Stop exporting the postgres password as a process-visible env var inside the backup container. Use a `.pgpass` file generated at runtime from a Docker secret so `pg_dump` reads credentials from disk (mode 0600) instead of environment. +**Architecture:** PostgreSQL clients automatically read `~/.pgpass` (or `$PGPASSFILE`) when no password is supplied. Docker secrets give us the password as a file under `/run/secrets/postgres_password`. The backup script writes a temporary `.pgpass` from the secret, runs `pg_dump`, then exits — credentials never appear in `/proc//environ`. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`docker-compose.prod.yml` lines 118–143 define a backup service that sets `PGPASSWORD: ${POSTGRES_PASSWORD}`. This means: + +- The password is interpolated from the host shell into the compose config (visible in `docker compose config`). +- It is exported into every child process started by `crond` and `pg_dump`. +- It appears in `docker inspect backup` and `/proc//environ` on the host. +- Any subprocess crash dump or exec hook that captures the environment leaks the password. + +`docker/backup.sh` line 7 also relies on `$PGUSER`/`$PGHOST` from the env block. + +This plan depends on Finding 2 (Docker secrets migration) — apply that first or alongside this one. + +## Files + +- `docker-compose.prod.yml` (backup service block, lines 118–143) +- `docker/backup.sh` + +## Tasks + +### Task 1: Switch the backup service to a Docker secret + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n 'PGPASSWORD' docker-compose.prod.yml docker/backup.sh + ``` +- [ ] **Step 2: Run** — expect `docker-compose.prod.yml:123` and any references in `backup.sh`. +- [ ] **Step 3: Apply fix.** Replace the backup service block (lines 118–143) with: + ```yaml + backup: + image: postgres:18-alpine + environment: + PGHOST: postgres + PGUSER: fluxcore + PGDATABASE: fluxcore + PGPASSFILE: /home/postgres/.pgpass + BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7} + BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 2 * * *} + volumes: + - ./docker/backup.sh:/backup.sh:ro + - backups:/backups + secrets: + - postgres_password + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -eu + # Build .pgpass from the mounted secret (mode 0600 required by libpq) + mkdir -p /home/postgres + printf '%s:%s:%s:%s:%s\n' "$PGHOST" 5432 "$PGDATABASE" "$PGUSER" "$(cat /run/secrets/postgres_password)" > "$PGPASSFILE" + chmod 600 "$PGPASSFILE" + echo "$BACKUP_SCHEDULE /backup.sh" | crontab - + crond -f -l 2 + depends_on: + postgres: + condition: service_healthy + networks: + - backend + restart: always + deploy: + resources: + limits: + memory: 256M + ``` +- [ ] **Step 4: Verify.** + ```bash + CLIENT_ID=test docker compose -f docker-compose.prod.yml config | awk '/^ backup:/,/^ [a-z]+:/' > /tmp/backup.yml + grep -c 'PGPASSWORD' /tmp/backup.yml # expect 0 + grep 'PGPASSFILE' /tmp/backup.yml # expect /home/postgres/.pgpass + grep 'postgres_password' /tmp/backup.yml # expect listed under secrets + ``` +- [ ] **Step 5: Commit.** `fix(docker): use Docker secret + .pgpass for backup service instead of PGPASSWORD env` + +### Task 2: Update `docker/backup.sh` to rely on the `.pgpass` file + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n 'PGPASSWORD\|pg_dump' docker/backup.sh + ``` +- [ ] **Step 2: Run** — expect line 7 `pg_dump -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" | gzip > "$BACKUP_FILE"` (which already does NOT pass a password — good). +- [ ] **Step 3: Apply fix.** Tighten `docker/backup.sh` so a missing `.pgpass` fails loudly: + ```bash + #!/bin/sh + set -eu + + : "${PGPASSFILE:?PGPASSFILE must be set}" + if [ ! -f "$PGPASSFILE" ]; then + echo "[$(date)] FATAL: $PGPASSFILE missing — refusing to run backup" >&2 + exit 2 + fi + + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + BACKUP_FILE="/backups/fluxcore_${TIMESTAMP}.sql.gz" + + pg_dump -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" | gzip > "$BACKUP_FILE" + + # Remove backups older than retention period + find /backups -name "fluxcore_*.sql.gz" -mtime +"${BACKUP_RETENTION_DAYS:-7}" -delete + + echo "[$(date)] Backup completed: $BACKUP_FILE" + ``` +- [ ] **Step 4: Verify.** + ```bash + sh -n docker/backup.sh # syntax check + grep -c PGPASSWORD docker/backup.sh # expect 0 + ``` +- [ ] **Step 5: Commit.** `fix(backup): rely on PGPASSFILE and fail closed when missing` + +### Task 3: Smoke test against a real backup run + +- [ ] **Step 1: Write verification check.** Bring up postgres + backup using a throwaway secret: + ```bash + mkdir -p secrets && echo "smokepw" > secrets/postgres_password + CLIENT_ID=x docker compose -f docker-compose.prod.yml --profile full up -d postgres backup + sleep 5 + docker compose -f docker-compose.prod.yml exec backup sh -c 'cat $PGPASSFILE && stat -c %a $PGPASSFILE' + docker compose -f docker-compose.prod.yml exec backup sh -c 'env | grep -i pgpass; env | grep -i pgpassword || echo "no PGPASSWORD - good"' + docker compose -f docker-compose.prod.yml exec backup /backup.sh + docker compose -f docker-compose.prod.yml exec backup ls -lh /backups/ + ``` +- [ ] **Step 2: Run** — expect `.pgpass` exists with mode `600`, no `PGPASSWORD` in env, and a non-empty `fluxcore_*.sql.gz` file. +- [ ] **Step 3: Apply fix.** No-op — verification only. +- [ ] **Step 4: Verify.** `docker compose -f docker-compose.prod.yml down -v` cleans up. `rm secrets/postgres_password`. +- [ ] **Step 5: Commit.** No commit if Tasks 1–2 are clean; otherwise `fix(backup): smoke-test follow-up`. diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-04-dockerfile-nonroot.md b/docs/superpowers/plans/2026-04-07-sec-infra-04-dockerfile-nonroot.md new file mode 100644 index 0000000..16389ea --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-04-dockerfile-nonroot.md @@ -0,0 +1,101 @@ +# Dev/Test Docker Stages Run as Root — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** MEDIUM +**Goal:** Drop root in the `development` and `test` Dockerfile stages so a process compromise doesn't grant root inside the container (and, on Linux with default user namespaces, root on the host bind mount). +**Architecture:** The base `node:22-alpine` image ships with an unprivileged `node` user (uid 1000). The two production stages already use `USER node`. We need to make sure `/app` is owned by that user before switching, otherwise pnpm and turbo can't write to `node_modules` / `.turbo`. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`Dockerfile` lines 25–31: + +```dockerfile +FROM deps AS development +COPY . . +CMD ["pnpm", "dev"] + +FROM deps AS test +COPY . . +CMD ["pnpm", "test"] +``` + +Both stages inherit `USER root` from `base`. Compose uses these stages for `bot`, `dashboard`, `preview-bot`, `preview-dashboard`. A compromised dependency or RCE in dev/test runs as root inside the container, can write anywhere on bind mounts, and bypasses any per-user filesystem ACLs the host might rely on. + +The production stages (lines 67, 95) already use `USER node`, so the pattern is established. + +## Files + +- `Dockerfile` + +## Tasks + +### Task 1: Make `deps` give the `node` user ownership of `/app` + +- [ ] **Step 1: Write verification check.** Build the existing dev stage and inspect uid: + ```bash + docker build --target development -t fluxcore-dev-old . + docker run --rm fluxcore-dev-old id -u + ``` +- [ ] **Step 2: Run** — expect `0` (root). +- [ ] **Step 3: Apply fix.** Edit `Dockerfile` lines 11–31: + ```dockerfile + FROM base AS deps + COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ + COPY packages/config/package.json ./packages/config/ + COPY packages/types/package.json ./packages/types/ + COPY packages/utils/package.json ./packages/utils/ + COPY packages/database/package.json ./packages/database/ + COPY packages/systems/package.json ./packages/systems/ + COPY packages/i18n/package.json ./packages/i18n/ + COPY apps/bot/package.json ./apps/bot/ + COPY apps/dashboard/package.json ./apps/dashboard/ + COPY packages/database/prisma ./packages/database/prisma/ + COPY packages/database/prisma.config.ts ./packages/database/ + RUN pnpm install --frozen-lockfile \ + && chown -R node:node /app + + FROM deps AS development + COPY --chown=node:node . . + USER node + CMD ["pnpm", "dev"] + + FROM deps AS test + COPY --chown=node:node . . + USER node + CMD ["pnpm", "test"] + ``` +- [ ] **Step 4: Verify.** + ```bash + docker build --target development -t fluxcore-dev-new . + docker run --rm fluxcore-dev-new id -u # expect 1000 + docker run --rm fluxcore-dev-new sh -c 'touch /app/.write-test && echo OK' # expect OK + docker build --target test -t fluxcore-test-new . + docker run --rm fluxcore-test-new id -u # expect 1000 + ``` +- [ ] **Step 5: Commit.** `fix(docker): drop root in development and test stages` + +### Task 2: Sanity-check compose dev workflow still works with bind mounts + +- [ ] **Step 1: Write verification check.** Bind-mounted host files have host uid/gid; the `node` user (uid 1000) inside the container needs read access. On most Linux dev hosts the developer is also uid 1000 — verify: + ```bash + id -u + ls -ld apps/bot + ``` +- [ ] **Step 2: Run** — note the uid; if it isn't 1000 the developer may need to add `user: "${UID}:${GID}"` to the compose service. Document this in the verification step. +- [ ] **Step 3: Apply fix.** If host uid != 1000, add to `docker-compose.yml` bot/dashboard services (and preview-*): + ```yaml + user: "${UID:-1000}:${GID:-1000}" + ``` + And document in `docs/development.md` (or wherever local-dev docs live) that developers should `export UID GID` before `pnpm dev` if they aren't on uid 1000. +- [ ] **Step 4: Verify.** + ```bash + docker compose --profile bot up -d bot + docker compose exec bot id + docker compose exec bot sh -c 'touch /app/apps/bot/.devtest && rm /app/apps/bot/.devtest && echo OK' + docker compose --profile bot down + ``` + Expect uid != 0 and the touch to succeed. +- [ ] **Step 5: Commit.** `fix(docker): document non-root dev workflow with bind mounts` diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-05-dev-postgres-password.md b/docs/superpowers/plans/2026-04-07-sec-infra-05-dev-postgres-password.md new file mode 100644 index 0000000..5f00c33 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-05-dev-postgres-password.md @@ -0,0 +1,95 @@ +# Hardcoded Dev Postgres Password — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** MEDIUM +**Goal:** Allow developers to override the local Postgres password via environment instead of leaving the literal `fluxcore` value baked into `docker-compose.yml`. Keep `fluxcore` as the documented default for friction-free local dev so this is purely additive. +**Architecture:** docker-compose's `${VAR:-default}` syntax interpolates from the host shell or `.env` (compose's auto-loaded file). We can also templatise the documented `DATABASE_URL` accordingly. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`docker-compose.yml:136` hardcodes `POSTGRES_PASSWORD: fluxcore`. While dev-only, this means: + +- Developers can't override the password without editing tracked files (causing dirty git state and accidental commits). +- The literal string is duplicated across `.env.example` (`DATABASE_URL=postgresql://fluxcore:fluxcore@postgres:5432/fluxcore`) and the compose file, so they drift. +- Forming a habit of "the password is in compose" trains developers to commit secrets. + +(pgAdmin defaults are addressed in a separate plan: `sec-infra-10-pgadmin-credentials.md`.) + +## Files + +- `docker-compose.yml` (postgres service, lines 132–149) +- `.env.example` + +## Tasks + +### Task 1: Parameterise the dev postgres password + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n 'POSTGRES_PASSWORD' docker-compose.yml + ``` +- [ ] **Step 2: Run** — expect line 136 with literal `fluxcore`. +- [ ] **Step 3: Apply fix.** Edit lines 132–149: + ```yaml + postgres: + image: postgres:18-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-fluxcore} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fluxcore} + POSTGRES_DB: ${POSTGRES_DB:-fluxcore} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fluxcore}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - fluxcore + restart: unless-stopped + ``` +- [ ] **Step 4: Verify.** + ```bash + docker compose -f docker-compose.yml config | grep -A4 'postgres:' | grep POSTGRES_PASSWORD + POSTGRES_PASSWORD=overridden docker compose -f docker-compose.yml config | grep POSTGRES_PASSWORD + ``` + First command must show `POSTGRES_PASSWORD: fluxcore` (default), second must show `overridden`. +- [ ] **Step 5: Commit.** `fix(docker): allow overriding dev POSTGRES_PASSWORD via env` + +### Task 2: Document the override in `.env.example` + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n 'POSTGRES_PASSWORD' .env.example + ``` +- [ ] **Step 2: Run** — expect only the production-only block at line 52. +- [ ] **Step 3: Apply fix.** Update `.env.example` lines 14–17: + ``` + # Database (PostgreSQL) - points to Docker container hostname + -DATABASE_URL=postgresql://fluxcore:fluxcore@postgres:5432/fluxcore + +DATABASE_URL=postgresql://fluxcore:fluxcore@postgres:5432/fluxcore + +# Optional dev override — defaults to "fluxcore" if unset + +# POSTGRES_USER=fluxcore + +# POSTGRES_PASSWORD=fluxcore + +# POSTGRES_DB=fluxcore + ``` +- [ ] **Step 4: Verify.** `grep -c 'POSTGRES_PASSWORD' .env.example` returns at least 2 (one dev, one prod). +- [ ] **Step 5: Commit.** `docs(env): document optional dev postgres overrides` + +### Task 3: Smoke test the override path + +- [ ] **Step 1: Write verification check.** + ```bash + POSTGRES_PASSWORD=smoke-pw docker compose --profile bot up -d postgres + sleep 5 + docker compose exec postgres psql -U fluxcore -c 'select 1' postgres + ``` +- [ ] **Step 2: Run** — expect the `select 1` to succeed (proves the password got through). +- [ ] **Step 3: Apply fix.** No-op. +- [ ] **Step 4: Verify.** `docker compose --profile bot down -v`. +- [ ] **Step 5: Commit.** No commit. diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-06-bot-sync-secret-fail-fast.md b/docs/superpowers/plans/2026-04-07-sec-infra-06-bot-sync-secret-fail-fast.md new file mode 100644 index 0000000..d68911e --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-06-bot-sync-secret-fail-fast.md @@ -0,0 +1,128 @@ +# `BOT_SYNC_SECRET` Production Fail-Fast — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** MEDIUM +**Goal:** Make the bot ↔ dashboard cache-sync HMAC secret a hard requirement in production. Today it silently auto-generates if unset, which breaks every restart in two ways: (1) the dashboard and bot end up with different secrets and sync requests fail, (2) operators have no signal that they forgot to provision the secret. +**Architecture:** `packages/config/src/index.ts:53–54` calls `randomBytes(32).toString("hex")` when `BOT_SYNC_SECRET` is unset. This must throw in production. Coordinate with the auth-agent plan `2026-04-07-sec-auth-01-session-secret-fail-fast.md` which addresses `DASHBOARD_SESSION_SECRET` similarly — keep both fixes consistent (same error format, same NODE_ENV check). +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`packages/config/src/index.ts:52–54`: + +```typescript +const botSyncPort = Number(process.env.BOT_SYNC_PORT) || 3001; +const botSyncSecret = + process.env.BOT_SYNC_SECRET || randomBytes(32).toString("hex"); +``` + +Failure modes: + +- **Split brain.** Bot container and dashboard container each generate a different random secret, so HMACs never validate. Cache-sync requests are silently rejected (dashboard config changes never propagate to the bot). +- **No signal.** Nothing is logged when the fallback fires; operators discover the problem only when a feature toggle "doesn't work." +- **Dev sloppiness in prod.** A `.env.prod` that simply omits the var ships happily, with no fail-closed behavior. + +The dev story (auto-generate when missing) is fine and should remain. + +## Files + +- `packages/config/src/index.ts` +- `packages/config/tests/index.test.ts` (create or extend) + +## Tasks + +### Task 1: Add a failing Vitest case for prod-mode fail-fast + +- [ ] **Step 1: Write verification test.** Create `packages/config/tests/bot-sync-secret.test.ts`: + ```typescript + import { describe, it, expect, beforeEach, vi } from "vitest"; + + describe("BOT_SYNC_SECRET", () => { + beforeEach(() => { + vi.resetModules(); + process.env.DISCORD_TOKEN = "x"; + process.env.CLIENT_ID = "y"; + process.env.LAVALINK_PASSWORD = "z"; + delete process.env.BOT_SYNC_SECRET; + }); + + it("auto-generates in development", async () => { + process.env.NODE_ENV = "development"; + const { config } = await import("../src/index"); + expect(config.botSyncSecret).toMatch(/^[0-9a-f]{64}$/); + }); + + it("throws in production when unset", async () => { + process.env.NODE_ENV = "production"; + await expect(import("../src/index")).rejects.toThrow(/BOT_SYNC_SECRET/); + }); + + it("uses provided value in production", async () => { + process.env.NODE_ENV = "production"; + process.env.BOT_SYNC_SECRET = "a".repeat(64); + const { config } = await import("../src/index"); + expect(config.botSyncSecret).toBe("a".repeat(64)); + }); + + it("rejects too-short values in production", async () => { + process.env.NODE_ENV = "production"; + process.env.BOT_SYNC_SECRET = "tooshort"; + await expect(import("../src/index")).rejects.toThrow(/at least 32/); + }); + }); + ``` +- [ ] **Step 2: Run** `pnpm --filter @fluxcore/config test bot-sync-secret` — expect failures (current code doesn't throw and doesn't validate length). +- [ ] **Step 3: Apply fix.** Edit `packages/config/src/index.ts:52–54`: + ```typescript + - const botSyncPort = Number(process.env.BOT_SYNC_PORT) || 3001; + - const botSyncSecret = + - process.env.BOT_SYNC_SECRET || randomBytes(32).toString("hex"); + + const botSyncPort = Number(process.env.BOT_SYNC_PORT) || 3001; + + const isProd = process.env.NODE_ENV === "production"; + + const rawBotSyncSecret = process.env.BOT_SYNC_SECRET; + + if (isProd) { + + if (!rawBotSyncSecret) { + + throw new Error( + + "BOT_SYNC_SECRET is required in production. Generate with: openssl rand -hex 32", + + ); + + } + + if (rawBotSyncSecret.length < 32) { + + throw new Error( + + "BOT_SYNC_SECRET must be at least 32 characters in production", + + ); + + } + + } + + const botSyncSecret = + + rawBotSyncSecret || randomBytes(32).toString("hex"); + ``` +- [ ] **Step 4: Verify.** `pnpm --filter @fluxcore/config test` — all four cases green. `pnpm typecheck` clean. +- [ ] **Step 5: Commit.** `fix(config): require BOT_SYNC_SECRET in production with min length 32` + +### Task 2: Cross-check the auth-agent's session-secret fix uses the same pattern + +- [ ] **Step 1: Write verification check.** + ```bash + cat docs/superpowers/plans/2026-04-07-sec-auth-01-session-secret-fail-fast.md | grep -A2 'NODE_ENV' + ``` +- [ ] **Step 2: Run** — confirm the auth plan also keys off `process.env.NODE_ENV === "production"` and uses the same error message style. If they diverge, raise a comment in PR review so both PRs land with consistent UX. +- [ ] **Step 3: Apply fix.** No code change. If the auth plan diverges, file a one-line follow-up in the auth PR. +- [ ] **Step 4: Verify.** Both plan files mention `NODE_ENV === "production"` and `openssl rand -hex 32`. +- [ ] **Step 5: Commit.** No commit. + +### Task 3: Update `.env.example` to flag the requirement + +- [ ] **Step 1: Write verification check.** `grep -n BOT_SYNC_SECRET .env.example` — expect line 28 with empty value and a comment "generate a random string". +- [ ] **Step 2: Run** above command. +- [ ] **Step 3: Apply fix.** Edit `.env.example` lines 27–28: + ``` + -# Shared secret for cache sync authentication (generate a random string) + -BOT_SYNC_SECRET= + +# Shared secret for cache sync authentication. + +# REQUIRED in production (NODE_ENV=production); auto-generated in development. + +# Must be at least 32 characters. Generate with: openssl rand -hex 32 + +BOT_SYNC_SECRET= + ``` +- [ ] **Step 4: Verify.** `grep -A3 BOT_SYNC_SECRET .env.example` shows the new comment. +- [ ] **Step 5: Commit.** `docs(env): document BOT_SYNC_SECRET production requirement` diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-07-vite-bind-localhost.md b/docs/superpowers/plans/2026-04-07-sec-infra-07-vite-bind-localhost.md new file mode 100644 index 0000000..112e928 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-07-vite-bind-localhost.md @@ -0,0 +1,118 @@ +# Vite Dev Server Binds 0.0.0.0 — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** LOW +**Goal:** Default the Vite dev server to `127.0.0.1`. Only bind to `0.0.0.0` when an explicit `VITE_HOST=0.0.0.0` env var is set (needed for Docker, where the container's loopback isn't reachable from the host). +**Architecture:** Vite's `server.host` accepts a string. Inside the dashboard Docker container we already control the env (`docker-compose.yml`), so we can set `VITE_HOST=0.0.0.0` only there. Developers running Vite on the host bare-metal then get safe `127.0.0.1` by default. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`apps/dashboard/vite.config.ts:21` hardcodes `host: "0.0.0.0"`. When a developer runs `pnpm --filter @fluxcore/dashboard dev` directly on their laptop (not via the docker compose stack), the Vite dev server is reachable from any other host on the network. The dev server includes: + +- Source maps and full source tree +- HMR websocket that can run arbitrary plugin code in some Vite advisories +- The `/api` proxy that forwards to the local Fastify on port 3000 (which may be running with dev session secrets) + +This is a hostile-network exposure. Defaulting to loopback removes the risk while preserving the Docker workflow via an opt-in env var. + +## Files + +- `apps/dashboard/vite.config.ts` +- `docker-compose.yml` (dashboard service) + +## Tasks + +### Task 1: Default Vite to loopback, opt-in to all-interfaces + +- [ ] **Step 1: Write verification test.** Add `apps/dashboard/tests/vite-config.test.ts`: + ```typescript + import { describe, it, expect, beforeEach } from "vitest"; + + async function loadConfig() { + // vite.config.ts uses defineConfig which returns the object directly + const mod = await import("../vite.config"); + return mod.default as { server: { host: string } }; + } + + describe("vite.config server.host", () => { + beforeEach(() => { + delete process.env.VITE_HOST; + // bust import cache + const key = require.resolve("../vite.config"); + delete require.cache?.[key]; + }); + + it("defaults to 127.0.0.1", async () => { + const cfg = await loadConfig(); + expect(cfg.server.host).toBe("127.0.0.1"); + }); + + it("uses VITE_HOST when set", async () => { + process.env.VITE_HOST = "0.0.0.0"; + const cfg = await loadConfig(); + expect(cfg.server.host).toBe("0.0.0.0"); + }); + }); + ``` +- [ ] **Step 2: Run** `pnpm --filter @fluxcore/dashboard test vite-config` — expect failure (current host is `0.0.0.0`). +- [ ] **Step 3: Apply fix.** Edit `apps/dashboard/vite.config.ts:19–22`: + ```typescript + - server: { + - port: 5173, + - host: "0.0.0.0", + + server: { + + port: 5173, + + host: process.env.VITE_HOST || "127.0.0.1", + ``` +- [ ] **Step 4: Verify.** `pnpm --filter @fluxcore/dashboard test vite-config` passes. +- [ ] **Step 5: Commit.** `fix(dashboard): default Vite dev server to 127.0.0.1, opt in via VITE_HOST` + +### Task 2: Set `VITE_HOST=0.0.0.0` inside the docker compose dashboard service + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n VITE_HOST docker-compose.yml + ``` +- [ ] **Step 2: Run** — expect no match. +- [ ] **Step 3: Apply fix.** Edit `docker-compose.yml` dashboard service (lines 43–65) and preview-dashboard (lines 92–113), adding to each `environment:` block (create the block if absent): + ```yaml + dashboard: + build: + context: . + target: development + command: sh -c "pnpm install --frozen-lockfile && pnpm turbo run dev --filter=@fluxcore/dashboard" + + environment: + + VITE_HOST: "0.0.0.0" + volumes: + - ./packages:/app/packages + - ./apps:/app/apps + - ./turbo.json:/app/turbo.json + ports: + - "3000:3000" + - "5173:5173" + env_file: + - .env.dev + ``` +- [ ] **Step 4: Verify.** + ```bash + docker compose --profile dashboard config | grep -A1 VITE_HOST + ``` + Expect `VITE_HOST: 0.0.0.0` under the dashboard service. +- [ ] **Step 5: Commit.** `fix(docker): set VITE_HOST=0.0.0.0 inside dashboard container` + +### Task 3: Smoke test loopback default + +- [ ] **Step 1: Write verification check.** Run Vite directly on the host and confirm it binds loopback: + ```bash + cd apps/dashboard + timeout 8 pnpm exec vite --port 5173 & + sleep 4 + ss -tlnp | grep ':5173 ' + ``` +- [ ] **Step 2: Run** — expect `127.0.0.1:5173` (NOT `0.0.0.0:5173` or `*:5173`). +- [ ] **Step 3: Apply fix.** No-op. +- [ ] **Step 4: Verify.** `kill %1` and `ss -tlnp | grep 5173` (expect nothing). +- [ ] **Step 5: Commit.** No commit. diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-08-ci-security-scanning.md b/docs/superpowers/plans/2026-04-07-sec-infra-08-ci-security-scanning.md new file mode 100644 index 0000000..dcea0d1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-08-ci-security-scanning.md @@ -0,0 +1,184 @@ +# CI Security Scanning Workflow — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** HIGH (because nothing currently blocks regressions) +**Goal:** Add a GitHub Actions workflow that runs on every PR and on pushes to `main` and fails the build if (a) `pnpm audit` reports a HIGH/CRITICAL advisory, (b) gitleaks finds a secret, or (c) Trivy finds a HIGH/CRITICAL vulnerability in either of the production container images. +**Architecture:** A single workflow file `.github/workflows/security.yml` with three parallel jobs. The Trivy job builds the bot and dashboard images using the existing multi-stage Dockerfile (`production-bot`, `production-dashboard`). +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +There is no automated security scanning. Without CI gates: + +- A new dependency with a known CVE merges silently. +- A developer accidentally commits a real Discord token, OAuth refresh token (note: `lavalink/application.yml` already contains a real-looking YouTube refresh token), or `.env.prod`. +- Container base image drift introduces critical OS-level CVEs that ship to production. + +The other 11 plans in this batch fix point-in-time issues; this plan ensures regressions are caught. + +## Files + +- `.github/workflows/security.yml` (CREATE) + +## Tasks + +### Task 1: Create the security workflow + +- [ ] **Step 1: Write verification check.** + ```bash + ls .github/workflows/security.yml 2>&1 + ``` +- [ ] **Step 2: Run** — expect "No such file or directory". +- [ ] **Step 3: Apply fix.** Create `.github/workflows/security.yml`: + ```yaml + name: Security + + on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + + permissions: + contents: read + security-events: write + + jobs: + pnpm-audit: + name: pnpm audit (high+) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.28.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Audit (fail on high or critical) + run: pnpm audit --audit-level=high + + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true + + trivy-bot: + name: trivy (bot image) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build bot image + run: docker build --target production-bot -t fluxcore-bot:ci . + - name: Trivy scan + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: fluxcore-bot:ci + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + format: sarif + output: trivy-bot.sarif + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-bot.sarif + category: trivy-bot + + trivy-dashboard: + name: trivy (dashboard image) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build dashboard image + run: docker build --target production-dashboard -t fluxcore-dashboard:ci . + - name: Trivy scan + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: fluxcore-dashboard:ci + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + format: sarif + output: trivy-dashboard.sarif + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-dashboard.sarif + category: trivy-dashboard + + trivy-fs: + name: trivy (filesystem & config) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Trivy filesystem scan (config, IaC, secrets) + uses: aquasecurity/trivy-action@0.28.0 + with: + scan-type: fs + scan-ref: . + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + scanners: vuln,secret,config + ``` +- [ ] **Step 4: Verify.** + ```bash + cat .github/workflows/security.yml | head -5 + # YAML syntax check + python3 -c 'import yaml,sys; yaml.safe_load(open(".github/workflows/security.yml"))' && echo OK + # actionlint if available + command -v actionlint && actionlint .github/workflows/security.yml || echo "actionlint not installed (optional)" + ``` +- [ ] **Step 5: Commit.** `ci(security): add pnpm audit, gitleaks, and Trivy scanning workflow` + +### Task 2: Triage the first run + +- [ ] **Step 1: Write verification check.** Push the branch and open the PR; watch the Security workflow. +- [ ] **Step 2: Run** — expect at least one job to fail initially (gitleaks should flag the YouTube refresh token in `lavalink/application.yml:17`, and Trivy on the lavalink-pinned plugin sha may flag advisories). +- [ ] **Step 3: Apply fix.** For each finding: + - Real leaked credential (YouTube refresh token) → rotate it externally, then either remove from the file or move to a Docker secret. + - False positive → add to `.gitleaks.toml` allowlist with a justifying comment. + - HIGH dep advisory → upgrade or add `audit-ci`-style allowlist with expiry. +- [ ] **Step 4: Verify.** Re-run the workflow until all four jobs are green. +- [ ] **Step 5: Commit.** `fix(security): address initial CI scan findings` (one commit per logical fix). + +### Task 3: Add a baseline `.gitleaks.toml` + +- [ ] **Step 1: Write verification check.** `ls .gitleaks.toml 2>&1` — expect missing. +- [ ] **Step 2: Run** above. +- [ ] **Step 3: Apply fix.** Create `.gitleaks.toml`: + ```toml + title = "FluxCore gitleaks config" + + [extend] + useDefault = true + + [allowlist] + description = "Test fixtures and example placeholders" + paths = [ + '''.*\.test\.(ts|js)$''', + '''packages/systems/tests/.*''', + '''docs/.*''', + '''\.env\.example$''', + ] + ``` +- [ ] **Step 4: Verify.** Re-run the gitleaks job; allowlisted files no longer trigger. +- [ ] **Step 5: Commit.** `ci(security): add gitleaks allowlist for test fixtures and docs` diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-09-env-example-lavalink.md b/docs/superpowers/plans/2026-04-07-sec-infra-09-env-example-lavalink.md new file mode 100644 index 0000000..1b6c94a --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-09-env-example-lavalink.md @@ -0,0 +1,80 @@ +# `.env.example` Lavalink Default — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** LOW +**Goal:** Stop shipping a working default for `LAVALINK_PASSWORD` in `.env.example`. Leave the value blank with a generation hint so anyone copying the file is forced to set their own. +**Architecture:** `.env.example` is the canonical reference developers copy to `.env.dev` / `.env.prod`. Whatever it contains becomes the default everyone uses unless they think to change it. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`.env.example:46–47`: + +``` +# Lavalink server password (must match lavalink/application.yml) +LAVALINK_PASSWORD=youshallnotpass +``` + +This is the literal Lavalink default and is hardcoded across the codebase (see Finding 1). Even after Finding 1 is fixed, anyone copying `.env.example` would still get the unsafe value unless this line is corrected. + +## Files + +- `.env.example` + +## Tasks + +### Task 1: Blank the default and add a generation hint + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n 'LAVALINK_PASSWORD' .env.example + ``` +- [ ] **Step 2: Run** — expect line 47 with `LAVALINK_PASSWORD=youshallnotpass`. +- [ ] **Step 3: Apply fix.** Edit `.env.example` lines 40–47: + ``` + # === Lavalink (Music System) === + + # Lavalink server host (Docker service name in compose, or external IP) + LAVALINK_HOST=lavalink + # Lavalink server port + LAVALINK_PORT=2333 + -# Lavalink server password (must match lavalink/application.yml) + -LAVALINK_PASSWORD=youshallnotpass + +# Lavalink server password (REQUIRED). + +# Must match the LAVALINK_SERVER_PASSWORD env var the lavalink container reads. + +# Generate with: openssl rand -base64 32 + +LAVALINK_PASSWORD= + ``` +- [ ] **Step 4: Verify.** + ```bash + grep -n 'youshallnotpass' .env.example # expect no match + grep -A2 'LAVALINK_PASSWORD' .env.example | grep -i 'openssl rand' + ``` +- [ ] **Step 5: Commit.** `docs(env): blank LAVALINK_PASSWORD default and add generation hint` + +### Task 2: Add a CI grep guard so the literal can't be reintroduced + +- [ ] **Step 1: Write verification check.** This task can be merged into the security workflow created in `sec-infra-08`. Add a step to the `trivy-fs` job (or as a new tiny job) that fails if `youshallnotpass` ever reappears. +- [ ] **Step 2: Run** — without the guard, someone could re-add the literal and CI would pass. +- [ ] **Step 3: Apply fix.** Append to `.github/workflows/security.yml` (or to whichever lint workflow exists): + ```yaml + forbidden-strings: + name: forbidden strings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Block re-introduction of default lavalink password + run: | + if grep -RIn --exclude-dir=.git --exclude-dir=node_modules --exclude='*.md' 'youshallnotpass' .; then + echo "::error::The literal 'youshallnotpass' must never be committed." + exit 1 + fi + ``` +- [ ] **Step 4: Verify.** + ```bash + grep -RIn --exclude-dir=.git --exclude-dir=node_modules --exclude='*.md' 'youshallnotpass' . || echo OK + ``` + Expect `OK`. +- [ ] **Step 5: Commit.** `ci(security): block reintroduction of default lavalink password` diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-10-pgadmin-credentials.md b/docs/superpowers/plans/2026-04-07-sec-infra-10-pgadmin-credentials.md new file mode 100644 index 0000000..aa9c466 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-10-pgadmin-credentials.md @@ -0,0 +1,99 @@ +# pgAdmin Hardcoded admin/admin — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** MEDIUM +**Goal:** Replace the literal `admin@fluxcore.dev` / `admin` credentials in the pgAdmin dev service with environment-driven values. Keep dev convenience defaults but make them overridable. +**Architecture:** pgAdmin's official image reads `PGADMIN_DEFAULT_EMAIL` and `PGADMIN_DEFAULT_PASSWORD` from the environment on first boot. docker-compose can interpolate from the host shell with `${VAR:-default}` syntax. The pgAdmin container is gated behind the `tools` profile and bound to `127.0.0.1:5050`, so the blast radius is limited to the dev host — but it's still trivial to fix. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`docker-compose.yml:151–166`: + +```yaml +pgadmin: + image: dpage/pgadmin4:latest + environment: + PGADMIN_DEFAULT_EMAIL: admin@fluxcore.dev + PGADMIN_DEFAULT_PASSWORD: admin + PGADMIN_CONFIG_SERVER_MODE: "False" + ports: + - "127.0.0.1:5050:80" + ... +``` + +Hardcoded `admin/admin` is the canonical "test for default creds" target. If a developer accidentally exposes 5050 (binds to `0.0.0.0`, opens a tunnel, runs on a shared host) the entire dev database is one HTTP request away. Beyond the immediate exposure, hardcoded creds in tracked files train developers to leave defaults in place. + +## Files + +- `docker-compose.yml` (pgadmin block, lines 151–166) +- `.env.example` + +## Tasks + +### Task 1: Parameterise pgadmin credentials + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n 'PGADMIN_DEFAULT' docker-compose.yml + ``` +- [ ] **Step 2: Run** — expect lines 154–155 with literals. +- [ ] **Step 3: Apply fix.** Edit `docker-compose.yml` lines 151–166: + ```yaml + pgadmin: + image: dpage/pgadmin4:latest + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@fluxcore.local} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:?PGADMIN_PASSWORD is required to enable the tools profile} + PGADMIN_CONFIG_SERVER_MODE: "False" + ports: + - "127.0.0.1:5050:80" + depends_on: + postgres: + condition: service_healthy + networks: + - fluxcore + restart: unless-stopped + profiles: + - tools + ``` + The `:?` guard means running `docker compose --profile tools up pgadmin` without setting `PGADMIN_PASSWORD` errors out, preventing accidental defaults. +- [ ] **Step 4: Verify.** + ```bash + grep -n 'admin@fluxcore.dev' docker-compose.yml # expect empty + grep -n 'PGADMIN_DEFAULT_PASSWORD: admin$' docker-compose.yml # expect empty + unset PGADMIN_PASSWORD; docker compose --profile tools config 2>&1 | grep -i 'PGADMIN_PASSWORD is required' + PGADMIN_PASSWORD=devpw docker compose --profile tools config | grep -A2 pgadmin | grep PGADMIN_DEFAULT_PASSWORD + ``` + First negative grep: empty. Second: empty. Third: prints error. Fourth: shows `PGADMIN_DEFAULT_PASSWORD: devpw`. +- [ ] **Step 5: Commit.** `fix(docker): require PGADMIN_PASSWORD env var for pgadmin tools profile` + +### Task 2: Document in `.env.example` + +- [ ] **Step 1: Write verification check.** `grep -n PGADMIN .env.example` — expect no match. +- [ ] **Step 2: Run** above. +- [ ] **Step 3: Apply fix.** Append to `.env.example` after the existing dev section: + ``` + # === Dev tools profile (pgadmin) === + # Required when running: docker compose --profile tools up pgadmin + # PGADMIN_EMAIL=admin@fluxcore.local + # PGADMIN_PASSWORD= + ``` +- [ ] **Step 4: Verify.** `grep -c PGADMIN .env.example` returns >= 2. +- [ ] **Step 5: Commit.** `docs(env): document pgadmin credential overrides` + +### Task 3: Smoke test + +- [ ] **Step 1: Write verification check.** + ```bash + PGADMIN_PASSWORD=$(openssl rand -base64 12) docker compose --profile tools up -d pgadmin + sleep 6 + curl -sSf http://127.0.0.1:5050/login | grep -q 'pgAdmin' && echo OK + docker compose --profile tools down + ``` +- [ ] **Step 2: Run** — expect `OK`. +- [ ] **Step 3: Apply fix.** No-op. +- [ ] **Step 4: Verify.** `docker compose ps pgadmin` shows nothing after teardown. +- [ ] **Step 5: Commit.** No commit. diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-11-postgres-port-exposure.md b/docs/superpowers/plans/2026-04-07-sec-infra-11-postgres-port-exposure.md new file mode 100644 index 0000000..efcf7db --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-11-postgres-port-exposure.md @@ -0,0 +1,95 @@ +# Postgres Loopback Port Exposure — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** LOW +**Goal:** Document that the dev compose file intentionally exposes Postgres on `127.0.0.1:5432` for local debugging tools (psql, DataGrip, Prisma Studio), and add a `no-db-port` profile that suppresses the binding for developers who don't want it. +**Architecture:** docker-compose `ports:` cannot be conditionally enabled per profile out of the box. Workaround: split the binding into a sidecar service that runs only under a specific profile, or rely on a compose override file. The cleanest approach is the override-file pattern: keep the default binding in `docker-compose.yml`, and provide `docker-compose.no-db-port.yml` that re-declares the postgres service with `ports: []` to clear it. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +`docker-compose.yml:140–141`: + +```yaml +ports: + - "127.0.0.1:5432:5432" +``` + +This is bound to loopback, so it is **not** reachable from other hosts on the network. The risk is local-host process boundary only: + +- Any process on the dev machine (browser extensions, untrusted CLI tools, malware in `~/Downloads`) can connect using the well-known dev creds (`fluxcore`/`fluxcore`). +- After the dev-password fix (Finding 5) the password is overridable, but the loopback exposure remains. + +This is intentional and useful, but undocumented. We need to (a) state that intent in a comment, (b) give developers an opt-out, and (c) confirm it isn't accidentally exposed publicly. + +## Files + +- `docker-compose.yml` +- `docker-compose.no-db-port.yml` (CREATE) + +## Tasks + +### Task 1: Document the binding inline + +- [ ] **Step 1: Write verification check.** + ```bash + grep -B1 -A1 '127.0.0.1:5432' docker-compose.yml + ``` +- [ ] **Step 2: Run** — expect just the bare `ports:` block. +- [ ] **Step 3: Apply fix.** Edit `docker-compose.yml` lines 140–141: + ```yaml + - ports: + - - "127.0.0.1:5432:5432" + + # Loopback-only exposure for local debugging tools (psql, DataGrip, Prisma Studio). + + # NOT reachable from other hosts. To disable entirely, use the override: + + # docker compose -f docker-compose.yml -f docker-compose.no-db-port.yml up + + ports: + + - "127.0.0.1:5432:5432" + ``` +- [ ] **Step 4: Verify.** `grep -A1 'Loopback-only' docker-compose.yml` shows the comment. +- [ ] **Step 5: Commit.** `docs(docker): document intentional postgres loopback exposure` + +### Task 2: Provide a `no-db-port` override + +- [ ] **Step 1: Write verification check.** + ```bash + ls docker-compose.no-db-port.yml 2>&1 + ``` +- [ ] **Step 2: Run** — expect "No such file or directory". +- [ ] **Step 3: Apply fix.** Create `docker-compose.no-db-port.yml`: + ```yaml + # Apply with: docker compose -f docker-compose.yml -f docker-compose.no-db-port.yml up + # Suppresses the host loopback binding for the postgres service. + services: + postgres: + ports: !reset [] + ``` +- [ ] **Step 4: Verify.** + ```bash + docker compose -f docker-compose.yml config | awk '/postgres:/,/^ [a-z]/' | grep '5432' + # expect "127.0.0.1:5432:5432" + docker compose -f docker-compose.yml -f docker-compose.no-db-port.yml config | awk '/postgres:/,/^ [a-z]/' | grep '5432' || echo NO_PORT + # expect NO_PORT + ``` +- [ ] **Step 5: Commit.** `feat(docker): add no-db-port override to suppress postgres host binding` + +### Task 3: Verify no public exposure can sneak in + +- [ ] **Step 1: Write verification check.** Add a forbidden-string CI check (combine with `sec-infra-09` Task 2 if already added): + ```bash + grep -n '"5432:5432"\|"0.0.0.0:5432' docker-compose.yml docker-compose.prod.yml + ``` +- [ ] **Step 2: Run** — expect no match (both forms would mean a public binding). +- [ ] **Step 3: Apply fix.** Add to `.github/workflows/security.yml` `forbidden-strings` job: + ```yaml + - name: Block public postgres exposure + run: | + if grep -RIn --include='docker-compose*.yml' -E '"(0\.0\.0\.0:)?5432:5432"' .; then + echo "::error::Postgres must only bind 127.0.0.1, never 0.0.0.0 or unbound." + exit 1 + fi + ``` +- [ ] **Step 4: Verify.** Re-run the workflow on the PR; the new step passes. +- [ ] **Step 5: Commit.** `ci(security): block public postgres port exposure in compose files` diff --git a/docs/superpowers/plans/2026-04-07-sec-infra-12-dockerignore-env-guard.md b/docs/superpowers/plans/2026-04-07-sec-infra-12-dockerignore-env-guard.md new file mode 100644 index 0000000..5068b3d --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-infra-12-dockerignore-env-guard.md @@ -0,0 +1,131 @@ +# `.dockerignore` Validation & Env-File Guard — Fix Plan +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Severity:** LOW +**Goal:** Make sure `.env*` files can never sneak into a Docker build context, never get committed to git, and never silently bypass `.dockerignore`. Add a CI check that fails the build if a real `.env`, `.env.dev`, `.env.prod`, or `.env.local` is present in the working tree. +**Architecture:** `.dockerignore` already lists `.env`, `.env.dev`, `.env.prod` (lines 3–5), so they cannot enter the build context. We additionally need: (a) `.gitignore` to block them from being committed, and (b) a CI check that asserts they aren't in the worktree at all. +**Tech Stack:** Docker, docker-compose, Caddy, PostgreSQL, Lavalink, Vite + +--- + +## Vulnerability + +Current state: + +- `.dockerignore` lines 3–5 cover `.env`, `.env.dev`, `.env.prod` — good but doesn't cover `.env.local`, `.env.production`, `.env.*.local`. +- There's no CI assertion that those files aren't checked in. A developer can `git add -f .env.prod` and CI won't notice. +- There's no test that the `.dockerignore` actually keeps env files out of the build context (regressions silent). + +## Files + +- `.dockerignore` +- `.gitignore` +- `.github/workflows/security.yml` (extend, created in `sec-infra-08`) + +## Tasks + +### Task 1: Broaden `.dockerignore` to cover all env-file flavors + +- [ ] **Step 1: Write verification check.** + ```bash + cat .dockerignore + ``` +- [ ] **Step 2: Run** — confirm only `.env`, `.env.dev`, `.env.prod` are listed. +- [ ] **Step 3: Apply fix.** Replace lines 3–5 of `.dockerignore` with a glob pattern: + ``` + node_modules + dist + -.env + -.env.dev + -.env.prod + +.env + +.env.* + +!.env.example + .git + ``` +- [ ] **Step 4: Verify.** Build context test: + ```bash + printf 'SECRET=should-not-leak\n' > .env.local + docker build --target deps -t fluxcore-ignore-test . > /tmp/build.log 2>&1 + docker run --rm fluxcore-ignore-test sh -c 'ls /app/.env* 2>&1 || echo NO_ENV_FILES' + rm .env.local + ``` + Expect `NO_ENV_FILES` and the build log NOT to mention `.env.local`. +- [ ] **Step 5: Commit.** `fix(docker): expand .dockerignore env-file glob to cover all variants` + +### Task 2: Mirror the pattern in `.gitignore` + +- [ ] **Step 1: Write verification check.** + ```bash + grep -n '^\.env' .gitignore 2>&1 + ``` +- [ ] **Step 2: Run** — note current state (likely lists `.env`, `.env.dev`, `.env.prod`). +- [ ] **Step 3: Apply fix.** Ensure `.gitignore` contains: + ``` + .env + .env.* + !.env.example + ``` + Edit in place if those exact lines are missing. +- [ ] **Step 4: Verify.** + ```bash + touch .env.dev .env.prod .env.local + git status --porcelain | grep -E '\.env\.(dev|prod|local)' && echo LEAK || echo OK + rm .env.dev .env.prod .env.local + ``` + Expect `OK`. +- [ ] **Step 5: Commit.** `fix(git): use .env.* glob with .env.example exception` + +### Task 3: CI guard against committed env files + +- [ ] **Step 1: Write verification check.** Examine the worktree for tracked env files: + ```bash + git ls-files | grep -E '^\.env(\..+)?$' | grep -v '^\.env\.example$' || echo CLEAN + ``` +- [ ] **Step 2: Run** — expect `CLEAN`. +- [ ] **Step 3: Apply fix.** Add a job to `.github/workflows/security.yml`: + ```yaml + no-env-files: + name: no env files in repo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Assert no .env files are tracked + run: | + tracked=$(git ls-files | grep -E '^\.env(\..+)?$' | grep -v '^\.env\.example$' || true) + if [ -n "$tracked" ]; then + echo "::error::These env files must not be committed:" + echo "$tracked" + exit 1 + fi + - name: Assert .dockerignore covers env files + run: | + grep -qE '^\.env\*?$|^\.env\.\*$' .dockerignore || { + echo "::error::.dockerignore must include '.env' and '.env.*'" + exit 1 + } + ``` +- [ ] **Step 4: Verify.** YAML lint and a dry test by creating a fixture in a throwaway branch: + ```bash + python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/security.yml"))' && echo OK + ``` +- [ ] **Step 5: Commit.** `ci(security): block committed env files and verify .dockerignore coverage` + +### Task 4: Local pre-commit safety net (optional) + +- [ ] **Step 1: Write verification check.** `ls .git/hooks/pre-commit 2>&1` — likely missing. +- [ ] **Step 2: Run** above. +- [ ] **Step 3: Apply fix.** Add a documented hook snippet to `docs/development.md` (or wherever local-dev docs live) so devs can opt in: + ```bash + cat > .git/hooks/pre-commit <<'EOF' + #!/bin/sh + if git diff --cached --name-only | grep -E '^\.env(\..+)?$' | grep -v '^\.env\.example$'; then + echo "Refusing to commit .env files. Use .env.example for documented placeholders." + exit 1 + fi + EOF + chmod +x .git/hooks/pre-commit + ``` + Do NOT install this automatically — just document. +- [ ] **Step 4: Verify.** Manual: try `git commit` with a staged `.env.local` and confirm rejection. +- [ ] **Step 5: Commit.** `docs(dev): document optional pre-commit env-file guard` From 30fb7d53b71eb48aa937a7ca5248c3ebb70d285f Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 7 Apr 2026 16:04:50 +0200 Subject: [PATCH 2/2] fix(security): harden infrastructure and CI (sec-infra-01..12) Implements 12 infrastructure hardening plans covering secrets, containers, CI scanning, and dev environment defaults: - sec-infra-01: remove hardcoded Lavalink 'youshallnotpass' default; fail-fast in production if LAVALINK_PASSWORD unset - sec-infra-02: migrate production secrets from env_file to Docker secrets with *_FILE pattern; adds secret-files.ts resolver - sec-infra-03: backup service uses .pgpass from Docker secret instead of PGPASSWORD env var; backup.sh is fail-closed - sec-infra-04: Dockerfile dev/test stages now run as node user - sec-infra-05: parameterize dev postgres password with ${...:-fluxcore} - sec-infra-06: BOT_SYNC_SECRET production fail-fast (unset or <32) - sec-infra-07: Vite dev server defaults to 127.0.0.1; opt-in 0.0.0.0 via VITE_HOST env var - sec-infra-08: add .github/workflows/security.yml (pnpm audit + gitleaks + Trivy for bot/dashboard/filesystem) and .gitleaks.toml - sec-infra-09: blank LAVALINK_PASSWORD in .env.example + CI guard against reintroducing the hardcoded default - sec-infra-10: parameterize pgAdmin credentials with fail-fast guard - sec-infra-11: add docker-compose.no-db-port.yml override + CI guard against public postgres exposure - sec-infra-12: broaden .dockerignore and .gitignore to .env.* glob; CI guard blocking tracked env files New files: security.yml workflow, .gitleaks.toml, secret-files.ts, docker-compose.no-db-port.yml, vite-config.test.ts, 3 config tests, secrets/.gitkeep. Typecheck clean. Dashboard 281 pass / 15 baseline failures (no new regressions). Bot 325 pass / 16 baseline failures (no new regressions). --- .dockerignore | 4 +- .env.example | 48 +++++- .github/workflows/security.yml | 143 ++++++++++++++++++ .gitignore | 7 +- .gitleaks.toml | 11 +- Dockerfile | 9 +- apps/dashboard/tests/vite-config.test.ts | 25 +++ apps/dashboard/vite.config.ts | 2 +- docker-compose.no-db-port.yml | 5 + docker-compose.prod.yml | 81 ++++++++-- docker-compose.yml | 15 +- docker/backup.sh | 10 +- lavalink/application.yml | 2 +- packages/config/src/index.ts | 45 +++++- packages/config/src/secret-files.ts | 27 ++++ packages/config/tests/bot-sync-secret.test.ts | 35 +++++ packages/config/tests/index.test.ts | 22 +++ packages/config/tests/secret-files.test.ts | 33 ++++ secrets/.gitkeep | 0 19 files changed, 487 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/security.yml create mode 100644 apps/dashboard/tests/vite-config.test.ts create mode 100644 docker-compose.no-db-port.yml create mode 100644 packages/config/src/secret-files.ts create mode 100644 packages/config/tests/bot-sync-secret.test.ts create mode 100644 packages/config/tests/index.test.ts create mode 100644 packages/config/tests/secret-files.test.ts create mode 100644 secrets/.gitkeep diff --git a/.dockerignore b/.dockerignore index eaab46a..feb2116 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,8 @@ node_modules dist .env -.env.dev -.env.prod +.env.* +!.env.example .git .gitignore *.md diff --git a/.env.example b/.env.example index cdf285d..58dbb4d 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,16 @@ LOG_LEVEL=info # Database (PostgreSQL) - points to Docker container hostname DATABASE_URL=postgresql://fluxcore:fluxcore@postgres:5432/fluxcore +# Optional dev overrides for the local postgres container — defaults shown +# POSTGRES_USER=fluxcore +# POSTGRES_PASSWORD=fluxcore +# POSTGRES_DB=fluxcore + +# === Vite dev server (dashboard) === +# Defaults to 127.0.0.1 (loopback only). Set to 0.0.0.0 ONLY when running +# inside Docker — exposing Vite to all interfaces on a bare-metal host is +# unsafe. The docker-compose dashboard service sets this automatically. +# VITE_HOST=0.0.0.0 # === Bot only === @@ -24,7 +34,9 @@ GUILD_ID= # Port for the bot's internal HTTP cache sync server BOT_SYNC_PORT=3001 -# Shared secret for cache sync authentication (generate a random string) +# Shared secret for cache sync authentication. +# REQUIRED in production (NODE_ENV=production); auto-generated in development. +# Must be at least 32 characters. Generate with: openssl rand -hex 32 BOT_SYNC_SECRET= # URL the dashboard uses to reach the bot's sync server (Docker service name) BOT_SYNC_URL=http://bot:3001 @@ -43,8 +55,10 @@ DASHBOARD_SESSION_SECRET= LAVALINK_HOST=lavalink # Lavalink server port LAVALINK_PORT=2333 -# Lavalink server password (must match lavalink/application.yml) -LAVALINK_PASSWORD=youshallnotpass +# Lavalink server password (REQUIRED). +# Must match the LAVALINK_SERVER_PASSWORD env var the lavalink container reads. +# Generate with: openssl rand -base64 32 +LAVALINK_PASSWORD= # === Infrastructure (production only) === @@ -57,3 +71,31 @@ DASHBOARD_DOMAIN=localhost # Backup schedule (cron expression, default: daily at 2am) BACKUP_SCHEDULE=0 2 * * * BACKUP_RETENTION_DAYS=7 + +# === Dev tools profile (pgadmin) === +# Required when running: docker compose --profile tools up pgadmin +# PGADMIN_EMAIL=admin@fluxcore.local +# PGADMIN_PASSWORD= + +# === Production secrets (Docker secrets — DO NOT commit values) === +# In production, place each secret as a file under ./secrets/: +# secrets/discord_token +# secrets/dashboard_client_secret +# secrets/dashboard_session_secret +# secrets/bot_sync_secret +# secrets/lavalink_password +# secrets/postgres_password +# Generate strong values with: openssl rand -base64 32 +# +# The Node services read secrets via *_FILE env vars (see packages/config +# resolveSecretFiles). Example overrides if running outside docker-compose: +# DISCORD_TOKEN_FILE=/run/secrets/discord_token +# DASHBOARD_CLIENT_SECRET_FILE=/run/secrets/dashboard_client_secret +# DASHBOARD_SESSION_SECRET_FILE=/run/secrets/dashboard_session_secret +# BOT_SYNC_SECRET_FILE=/run/secrets/bot_sync_secret +# LAVALINK_PASSWORD_FILE=/run/secrets/lavalink_password +# POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password +# DATABASE_PASSWORD_FILE=/run/secrets/postgres_password # alias for deployments +# +# If DATABASE_URL is unset, it is constructed at startup from POSTGRES_USER, +# POSTGRES_PASSWORD (or POSTGRES_PASSWORD_FILE), POSTGRES_HOST, POSTGRES_DB. diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..6c76f72 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,143 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + +permissions: + contents: read + security-events: write + +jobs: + pnpm-audit: + name: pnpm audit (high+) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.28.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Audit (fail on high or critical) + run: pnpm audit --audit-level=high + + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true + + trivy-bot: + name: trivy (bot image) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build bot image + run: docker build --target production-bot -t fluxcore-bot:ci . + - name: Trivy scan + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: fluxcore-bot:ci + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + format: sarif + output: trivy-bot.sarif + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-bot.sarif + category: trivy-bot + + trivy-dashboard: + name: trivy (dashboard image) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build dashboard image + run: docker build --target production-dashboard -t fluxcore-dashboard:ci . + - name: Trivy scan + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: fluxcore-dashboard:ci + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + format: sarif + output: trivy-dashboard.sarif + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-dashboard.sarif + category: trivy-dashboard + + trivy-fs: + name: trivy (filesystem & config) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Trivy filesystem scan (config, IaC, secrets) + uses: aquasecurity/trivy-action@0.28.0 + with: + scan-type: fs + scan-ref: . + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + scanners: vuln,secret,config + + forbidden-strings: + name: forbidden strings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Block re-introduction of default lavalink password + run: | + if grep -RIn --exclude-dir=.git --exclude-dir=node_modules --exclude='*.md' 'youshallnotpass' .; then + echo "::error::The literal 'youshallnotpass' must never be committed." + exit 1 + fi + - name: Block public postgres exposure + run: | + if grep -RIn --include='docker-compose*.yml' -E '"(0\.0\.0\.0:)?5432:5432"' .; then + echo "::error::Postgres must only bind 127.0.0.1, never 0.0.0.0 or unbound." + exit 1 + fi + + no-env-files: + name: no env files in repo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Assert no .env files are tracked + run: | + tracked=$(git ls-files | grep -E '^\.env(\..+)?$' | grep -v '^\.env\.example$' || true) + if [ -n "$tracked" ]; then + echo "::error::These env files must not be committed:" + echo "$tracked" + exit 1 + fi + - name: Assert .dockerignore covers env files + run: | + grep -qE '^\.env\*?$|^\.env\.\*$' .dockerignore || { + echo "::error::.dockerignore must include '.env' and '.env.*'" + exit 1 + } diff --git a/.gitignore b/.gitignore index 316382c..82f514b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ node_modules/ dist/ .env -.env.dev -.env.prod +.env.* +!.env.example *.js.map *.d.ts !src/**/*.d.ts @@ -26,3 +26,6 @@ out/ apps/dashboard/tests/e2e/.auth/ # E2E HTML report apps/dashboard/tests/e2e/.report/ +# Production Docker secrets — keep the directory but never commit values +/secrets/* +!/secrets/.gitkeep diff --git a/.gitleaks.toml b/.gitleaks.toml index 6b77565..da0ad43 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,11 +1,14 @@ -title = "FluxCore Gitleaks Configuration" +title = "FluxCore gitleaks config" [extend] useDefault = true [allowlist] -description = "Allow common false positives" +description = "Test fixtures and example placeholders" paths = [ - '''\.env\.example''', + '''.*\.test\.(ts|js)$''', + '''packages/systems/tests/.*''', + '''docs/.*''', + '''\.env\.example$''', '''pnpm-lock\.yaml''', -] \ No newline at end of file +] diff --git a/Dockerfile b/Dockerfile index 7ec6b6e..f1b9a06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,14 +20,17 @@ COPY apps/bot/package.json ./apps/bot/ COPY apps/dashboard/package.json ./apps/dashboard/ COPY packages/database/prisma ./packages/database/prisma/ COPY packages/database/prisma.config.ts ./packages/database/ -RUN pnpm install --frozen-lockfile +RUN pnpm install --frozen-lockfile \ + && chown -R node:node /app FROM deps AS development -COPY . . +COPY --chown=node:node . . +USER node CMD ["pnpm", "dev"] FROM deps AS test -COPY . . +COPY --chown=node:node . . +USER node CMD ["pnpm", "test"] # ============================================ diff --git a/apps/dashboard/tests/vite-config.test.ts b/apps/dashboard/tests/vite-config.test.ts new file mode 100644 index 0000000..67ee67a --- /dev/null +++ b/apps/dashboard/tests/vite-config.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +// Source-level assertions on vite.config.ts. We can't load the config at +// runtime inside vitest (esbuild rejects the cache-busting query string), +// so we pin the host-binding logic by inspecting the source text. +describe("vite.config server.host", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(resolve(here, "../vite.config.ts"), "utf8"); + + it("defaults host to 127.0.0.1 (does NOT default to 0.0.0.0)", () => { + // Must reference VITE_HOST + expect(src).toContain("VITE_HOST"); + // Must have 127.0.0.1 as the fallback + expect(src).toMatch(/VITE_HOST[^}]*127\.0\.0\.1/); + // Must NOT have 0.0.0.0 as a hardcoded default (only via env var) + expect(src).not.toMatch(/host:\s*["']0\.0\.0\.0["']/); + }); + + it("reads host from process.env.VITE_HOST", () => { + expect(src).toContain("process.env.VITE_HOST"); + }); +}); diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index 200bf0a..1b5a852 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ }, server: { port: 5173, - host: "0.0.0.0", + host: process.env.VITE_HOST ?? "127.0.0.1", proxy: { "/auth": { target: "http://localhost:3000", diff --git a/docker-compose.no-db-port.yml b/docker-compose.no-db-port.yml new file mode 100644 index 0000000..fd2b315 --- /dev/null +++ b/docker-compose.no-db-port.yml @@ -0,0 +1,5 @@ +# Apply with: docker compose -f docker-compose.yml -f docker-compose.no-db-port.yml up +# Suppresses the host loopback binding for the postgres service. +services: + postgres: + ports: !reset [] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e9b9ac7..9b822e9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -4,9 +4,24 @@ services: context: . target: production-bot environment: - DATABASE_URL: postgresql://fluxcore:${POSTGRES_PASSWORD}@postgres:5432/fluxcore - env_file: - - .env.prod + NODE_ENV: production + DISCORD_TOKEN_FILE: /run/secrets/discord_token + DASHBOARD_SESSION_SECRET_FILE: /run/secrets/dashboard_session_secret + BOT_SYNC_SECRET_FILE: /run/secrets/bot_sync_secret + LAVALINK_PASSWORD_FILE: /run/secrets/lavalink_password + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + CLIENT_ID: ${CLIENT_ID:?CLIENT_ID required} + LAVALINK_HOST: lavalink + LAVALINK_PORT: "2333" + POSTGRES_HOST: postgres + POSTGRES_DB: fluxcore + POSTGRES_USER: fluxcore + secrets: + - discord_token + - dashboard_session_secret + - bot_sync_secret + - lavalink_password + - postgres_password depends_on: postgres: condition: service_healthy @@ -29,9 +44,14 @@ services: - ./lavalink/application.yml:/opt/Lavalink/application.yml:ro environment: - _JAVA_OPTIONS=-Xmx256m - - LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD:-youshallnotpass} + secrets: + - lavalink_password + entrypoint: + - sh + - -c + - 'export LAVALINK_SERVER_PASSWORD=$(cat /run/secrets/lavalink_password) && exec /opt/Lavalink/launch.sh' healthcheck: - test: ["CMD-SHELL", "wget -qO- --header='Authorization: youshallnotpass' http://localhost:2333/version || exit 1"] + test: ["CMD-SHELL", "wget -qO- --header=\"Authorization: $(cat /run/secrets/lavalink_password)\" http://localhost:2333/version || exit 1"] interval: 10s timeout: 5s retries: 5 @@ -51,9 +71,24 @@ services: context: . target: production-dashboard environment: - DATABASE_URL: postgresql://fluxcore:${POSTGRES_PASSWORD}@postgres:5432/fluxcore - env_file: - - .env.prod + NODE_ENV: production + DISCORD_TOKEN_FILE: /run/secrets/discord_token + DASHBOARD_CLIENT_SECRET_FILE: /run/secrets/dashboard_client_secret + DASHBOARD_SESSION_SECRET_FILE: /run/secrets/dashboard_session_secret + BOT_SYNC_SECRET_FILE: /run/secrets/bot_sync_secret + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + LAVALINK_PASSWORD_FILE: /run/secrets/lavalink_password + CLIENT_ID: ${CLIENT_ID:?CLIENT_ID required} + POSTGRES_HOST: postgres + POSTGRES_DB: fluxcore + POSTGRES_USER: fluxcore + secrets: + - discord_token + - dashboard_client_secret + - dashboard_session_secret + - bot_sync_secret + - postgres_password + - lavalink_password depends_on: postgres: condition: service_healthy @@ -73,8 +108,10 @@ services: image: postgres:18-alpine environment: POSTGRES_USER: fluxcore - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password POSTGRES_DB: fluxcore + secrets: + - postgres_password volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -120,16 +157,24 @@ services: environment: PGHOST: postgres PGUSER: fluxcore - PGPASSWORD: ${POSTGRES_PASSWORD} PGDATABASE: fluxcore + PGPASSFILE: /home/postgres/.pgpass BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7} + BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 2 * * *} volumes: - ./docker/backup.sh:/backup.sh:ro - backups:/backups + secrets: + - postgres_password entrypoint: ["/bin/sh", "-c"] command: - | - echo "${BACKUP_SCHEDULE:-0 2 * * *} /backup.sh" | crontab - + set -eu + # Build .pgpass from the mounted secret (mode 0600 required by libpq) + mkdir -p /home/postgres + printf '%s:%s:%s:%s:%s\n' "$$PGHOST" 5432 "$$PGDATABASE" "$$PGUSER" "$$(cat /run/secrets/postgres_password)" > "$$PGPASSFILE" + chmod 600 "$$PGPASSFILE" + echo "$$BACKUP_SCHEDULE /backup.sh" | crontab - crond -f -l 2 depends_on: postgres: @@ -152,3 +197,17 @@ volumes: caddy_data: caddy_config: backups: + +secrets: + discord_token: + file: ./secrets/discord_token + dashboard_client_secret: + file: ./secrets/dashboard_client_secret + dashboard_session_secret: + file: ./secrets/dashboard_session_secret + bot_sync_secret: + file: ./secrets/bot_sync_secret + lavalink_password: + file: ./secrets/lavalink_password + postgres_password: + file: ./secrets/postgres_password diff --git a/docker-compose.yml b/docker-compose.yml index e8c6673..ecacd65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,8 @@ services: context: . target: development command: sh -c "pnpm install --frozen-lockfile && pnpm turbo run dev --filter=@fluxcore/dashboard" + environment: + VITE_HOST: "0.0.0.0" volumes: - ./packages:/app/packages - ./apps:/app/apps @@ -96,6 +98,7 @@ services: command: sh -c "cd packages/database && pnpm prisma migrate deploy && cd /app && node apps/dashboard/dist/server/index.js" environment: - NODE_ENV=production + - VITE_HOST=0.0.0.0 volumes: - ./packages:/app/packages - ./apps:/app/apps @@ -132,15 +135,15 @@ services: postgres: image: postgres:18-alpine environment: - POSTGRES_USER: fluxcore - POSTGRES_PASSWORD: fluxcore - POSTGRES_DB: fluxcore + POSTGRES_USER: ${POSTGRES_USER:-fluxcore} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fluxcore} + POSTGRES_DB: ${POSTGRES_DB:-fluxcore} volumes: - pgdata:/var/lib/postgresql/data ports: - "127.0.0.1:5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U fluxcore"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fluxcore}"] interval: 5s timeout: 5s retries: 5 @@ -151,8 +154,8 @@ services: pgadmin: image: dpage/pgadmin4:latest environment: - PGADMIN_DEFAULT_EMAIL: admin@fluxcore.dev - PGADMIN_DEFAULT_PASSWORD: admin + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@fluxcore.local} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:?PGADMIN_PASSWORD is required to enable the tools profile} PGADMIN_CONFIG_SERVER_MODE: "False" ports: - "127.0.0.1:5050:80" diff --git a/docker/backup.sh b/docker/backup.sh index 7329912..5254d24 100755 --- a/docker/backup.sh +++ b/docker/backup.sh @@ -1,5 +1,11 @@ #!/bin/sh -set -e +set -eu + +: "${PGPASSFILE:?PGPASSFILE must be set}" +if [ ! -f "$PGPASSFILE" ]; then + echo "[$(date)] FATAL: $PGPASSFILE missing — refusing to run backup" >&2 + exit 2 +fi TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_FILE="/backups/fluxcore_${TIMESTAMP}.sql.gz" @@ -9,4 +15,4 @@ pg_dump -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" | gzip > "$BACKUP_FILE" # Remove backups older than retention period find /backups -name "fluxcore_*.sql.gz" -mtime +"${BACKUP_RETENTION_DAYS:-7}" -delete -echo "[$(date)] Backup completed: $BACKUP_FILE" \ No newline at end of file +echo "[$(date)] Backup completed: $BACKUP_FILE" diff --git a/lavalink/application.yml b/lavalink/application.yml index 223c1be..d3b705e 100644 --- a/lavalink/application.yml +++ b/lavalink/application.yml @@ -22,7 +22,7 @@ lavalink: snapshot: true repository: "https://maven.lavalink.dev/snapshots" server: - password: "youshallnotpass" + password: ${LAVALINK_SERVER_PASSWORD} sources: youtube: false bandcamp: true diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 36dda9b..9eb6f3e 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,10 +1,13 @@ import { randomBytes } from "node:crypto"; import dotenv from "dotenv"; +import { resolveSecretFiles } from "./secret-files.js"; // In a monorepo, turbo runs each package from its own directory (e.g. apps/bot/). // Search for .env at both the local dir and the workspace root (2 levels up). dotenv.config({ path: [".env", "../../.env", "../../.env.dev"] }); +export { resolveSecretFiles } from "./secret-files.js"; + export interface Config { token: string; clientId: string; @@ -23,6 +26,27 @@ export interface Config { } function loadConfig(): Config { + // Resolve any Docker-style *_FILE secret references first so the rest of + // loadConfig() can read process.env normally. + resolveSecretFiles([ + "DISCORD_TOKEN", + "DASHBOARD_CLIENT_SECRET", + "DASHBOARD_SESSION_SECRET", + "BOT_SYNC_SECRET", + "LAVALINK_PASSWORD", + "POSTGRES_PASSWORD", + "DATABASE_URL", + ]); + + // If DATABASE_URL is not set but POSTGRES_PASSWORD is (Docker secrets path), + // build it from the standard pieces. + if (!process.env.DATABASE_URL && process.env.POSTGRES_PASSWORD) { + const host = process.env.POSTGRES_HOST || "postgres"; + const db = process.env.POSTGRES_DB || "fluxcore"; + const user = process.env.POSTGRES_USER || "fluxcore"; + process.env.DATABASE_URL = `postgresql://${user}:${process.env.POSTGRES_PASSWORD}@${host}:5432/${db}`; + } + const token = process.env.DISCORD_TOKEN; const clientId = process.env.CLIENT_ID; const guildId = process.env.GUILD_ID || undefined; @@ -50,13 +74,30 @@ function loadConfig(): Config { dashboardSessionSecret || randomBytes(32).toString("hex"); const botSyncPort = Number(process.env.BOT_SYNC_PORT) || 3001; + const isProd = process.env.NODE_ENV === "production"; + const rawBotSyncSecret = process.env.BOT_SYNC_SECRET; + if (isProd) { + if (!rawBotSyncSecret) { + throw new Error( + "BOT_SYNC_SECRET is required in production. Generate with: openssl rand -hex 32", + ); + } + if (rawBotSyncSecret.length < 32) { + throw new Error( + "BOT_SYNC_SECRET must be at least 32 characters in production", + ); + } + } const botSyncSecret = - process.env.BOT_SYNC_SECRET || randomBytes(32).toString("hex"); + rawBotSyncSecret || randomBytes(32).toString("hex"); const botSyncUrl = process.env.BOT_SYNC_URL || undefined; const lavalinkHost = process.env.LAVALINK_HOST || "lavalink"; const lavalinkPort = Number(process.env.LAVALINK_PORT) || 2333; - const lavalinkPassword = process.env.LAVALINK_PASSWORD || "youshallnotpass"; + const lavalinkPassword = process.env.LAVALINK_PASSWORD; + if (!lavalinkPassword) { + throw new Error("Missing required environment variable: LAVALINK_PASSWORD"); + } return { token, diff --git a/packages/config/src/secret-files.ts b/packages/config/src/secret-files.ts new file mode 100644 index 0000000..7efe9f6 --- /dev/null +++ b/packages/config/src/secret-files.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; + +/** + * Resolve Docker-style `*_FILE` environment variables. + * + * For each name in `names`, if `${NAME}_FILE` is set, read its contents + * (trimming trailing whitespace) and assign the value to `process.env[NAME]`. + * + * If both `NAME` and `NAME_FILE` are set, this function throws — operators + * must pick one source of truth so a stale literal cannot mask a rotated + * secret file. + */ +export function resolveSecretFiles(names: readonly string[]): void { + for (const name of names) { + const fileVar = `${name}_FILE`; + const filePath = process.env[fileVar]; + const literal = process.env[name]; + if (filePath && literal) { + throw new Error( + `Both ${name} and ${fileVar} are set; choose one.`, + ); + } + if (filePath) { + process.env[name] = readFileSync(filePath, "utf8").trimEnd(); + } + } +} diff --git a/packages/config/tests/bot-sync-secret.test.ts b/packages/config/tests/bot-sync-secret.test.ts new file mode 100644 index 0000000..a809cbf --- /dev/null +++ b/packages/config/tests/bot-sync-secret.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +describe("BOT_SYNC_SECRET", () => { + beforeEach(() => { + vi.resetModules(); + process.env.DISCORD_TOKEN = "x"; + process.env.CLIENT_ID = "y"; + process.env.LAVALINK_PASSWORD = "z"; + delete process.env.BOT_SYNC_SECRET; + }); + + it("auto-generates in development", async () => { + process.env.NODE_ENV = "development"; + const { config } = await import("../src/index"); + expect(config.botSyncSecret).toMatch(/^[0-9a-f]{64}$/); + }); + + it("throws in production when unset", async () => { + process.env.NODE_ENV = "production"; + await expect(import("../src/index")).rejects.toThrow(/BOT_SYNC_SECRET/); + }); + + it("uses provided value in production", async () => { + process.env.NODE_ENV = "production"; + process.env.BOT_SYNC_SECRET = "a".repeat(64); + const { config } = await import("../src/index"); + expect(config.botSyncSecret).toBe("a".repeat(64)); + }); + + it("rejects too-short values in production", async () => { + process.env.NODE_ENV = "production"; + process.env.BOT_SYNC_SECRET = "tooshort"; + await expect(import("../src/index")).rejects.toThrow(/at least 32/); + }); +}); diff --git a/packages/config/tests/index.test.ts b/packages/config/tests/index.test.ts new file mode 100644 index 0000000..c2d2663 --- /dev/null +++ b/packages/config/tests/index.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +describe("lavalink password", () => { + beforeEach(() => { + vi.resetModules(); + process.env.DISCORD_TOKEN = "x"; + process.env.CLIENT_ID = "y"; + process.env.BOT_SYNC_SECRET = "a".repeat(64); + delete process.env.NODE_ENV; + }); + + it("throws when LAVALINK_PASSWORD is unset", async () => { + delete process.env.LAVALINK_PASSWORD; + await expect(import("../src/index")).rejects.toThrow(/LAVALINK_PASSWORD/); + }); + + it("uses the env value when present", async () => { + process.env.LAVALINK_PASSWORD = "from-env"; + const { config } = await import("../src/index"); + expect(config.lavalinkPassword).toBe("from-env"); + }); +}); diff --git a/packages/config/tests/secret-files.test.ts b/packages/config/tests/secret-files.test.ts new file mode 100644 index 0000000..b45ac65 --- /dev/null +++ b/packages/config/tests/secret-files.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { resolveSecretFiles } from "../src/secret-files"; + +describe("resolveSecretFiles", () => { + beforeEach(() => { + delete process.env.DISCORD_TOKEN; + delete process.env.DISCORD_TOKEN_FILE; + }); + + it("loads value from a *_FILE path into the base var", () => { + const dir = mkdtempSync(join(tmpdir(), "sec-")); + const path = join(dir, "token"); + writeFileSync(path, "abc123\n"); + process.env.DISCORD_TOKEN_FILE = path; + resolveSecretFiles(["DISCORD_TOKEN"]); + expect(process.env.DISCORD_TOKEN).toBe("abc123"); + }); + + it("leaves base var alone if no *_FILE present", () => { + process.env.DISCORD_TOKEN = "literal"; + resolveSecretFiles(["DISCORD_TOKEN"]); + expect(process.env.DISCORD_TOKEN).toBe("literal"); + }); + + it("throws when both are set", () => { + process.env.DISCORD_TOKEN = "literal"; + process.env.DISCORD_TOKEN_FILE = "/nope"; + expect(() => resolveSecretFiles(["DISCORD_TOKEN"])).toThrow(/both/i); + }); +}); diff --git a/secrets/.gitkeep b/secrets/.gitkeep new file mode 100644 index 0000000..e69de29