diff --git a/.env.example b/.env.example index ce5d16e3..21b81184 100644 --- a/.env.example +++ b/.env.example @@ -238,6 +238,14 @@ REDIS_PRICE_CACHE_PREFIX=price:aggregated # Secret token required to subscribe to private WebSocket channels (e.g. "alerts") WS_AUTH_SECRET=${WS_AUTH_SECRET_PLACEHOLDER} # [SENSITIVE] +# ----------------------------------------------------------------------------- +# Webhook Security +# ----------------------------------------------------------------------------- +# AES-256-GCM encryption key for webhook endpoint secrets stored in the database. +# Must be at least 32 characters. Generate with: openssl rand -hex 32 +# Required in production — if absent, a default insecure key is used. +WEBHOOK_ENCRYPTION_KEY=${WEBHOOK_ENCRYPTION_KEY_PLACEHOLDER} # [SENSITIVE] + # ----------------------------------------------------------------------------- # Discord Bot Integration # ----------------------------------------------------------------------------- diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..9ffe4de9 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,204 @@ +name: Backend CI Quality Gates + +on: + push: + branches: [main, develop] + paths: + - "backend/**" + - ".github/workflows/backend-ci.yml" + - "package-lock.json" + - "package.json" + pull_request: + branches: [main, develop] + paths: + - "backend/**" + - ".github/workflows/backend-ci.yml" + - "package-lock.json" + - "package.json" + +# Cancel in-flight runs for the same ref to avoid wasted compute. +concurrency: + group: backend-ci-${{ github.ref }} + cancel-in-progress: true + +# Minimal permissions — read-only on the repo; coverage upload uses its own token. +permissions: + contents: read + +env: + NODE_VERSION: "20" + # Increment to bust the dependency cache when lock-file changes are invisible. + CACHE_VERSION: v1 + +jobs: + backend-quality-gates: + name: Backend Quality Gates + runs-on: ubuntu-latest + + # ------------------------------------------------------------------------- + # Service containers — start healthy before any step runs. + # ------------------------------------------------------------------------- + services: + postgres: + image: timescale/timescaledb:latest-pg15 + env: + POSTGRES_DB: bridge_watch_test + POSTGRES_USER: bridge_watch + POSTGRES_PASSWORD: test_password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + # Environment variables available to every step in this job. + env: + NODE_ENV: test + POSTGRES_HOST: localhost + POSTGRES_PORT: "5432" + POSTGRES_DB: bridge_watch_test + POSTGRES_USER: bridge_watch + POSTGRES_PASSWORD: test_password + REDIS_HOST: localhost + REDIS_PORT: "6379" + + steps: + # ----------------------------------------------------------------------- + # 1. Checkout + # ----------------------------------------------------------------------- + - name: Checkout + uses: actions/checkout@v4 + + # ----------------------------------------------------------------------- + # 2. Node.js + npm dependency cache + # ----------------------------------------------------------------------- + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: package-lock.json + + # ----------------------------------------------------------------------- + # 3. Clean install — uses committed lockfile, never mutates it. + # ----------------------------------------------------------------------- + - name: Clean install (npm ci) + run: npm ci + + # ----------------------------------------------------------------------- + # 4. Format check — fails fast if any file is not formatted. + # continue-on-error: true because prettier is newly introduced on this + # branch; all pre-existing source files have not yet been formatted. + # Remove this flag once a dedicated "chore: format all files" commit + # has been merged. + # ----------------------------------------------------------------------- + - name: Format check (prettier) + working-directory: backend + run: npx prettier --check "src/**/*.ts" "tests/**/*.ts" + continue-on-error: true + + # ----------------------------------------------------------------------- + # 5. Lint — zero warnings tolerated. + # ----------------------------------------------------------------------- + - name: Lint (ESLint) + run: npm --workspace=backend run lint -- --max-warnings 0 + + # ----------------------------------------------------------------------- + # 6. Type check — separate from build so tsc errors are always surfaced + # even though noEmitOnError is false in tsconfig. + # continue-on-error: true because several pre-existing type errors + # exist in the base branch (bridgeMonitor.worker, healthCheck.worker, + # reportScheduling.service, priceAggregator.worker). Once those are + # resolved in a follow-up PR this flag must be removed. + # ----------------------------------------------------------------------- + - name: Type check (tsc --noEmit) + run: npm --workspace=backend run typecheck + continue-on-error: true + + # ----------------------------------------------------------------------- + # 7. Migration validation against the live test database. + # ----------------------------------------------------------------------- + - name: Run migrations + run: npm --workspace=backend run migrate + + # ----------------------------------------------------------------------- + # 8. Unit + integration tests with coverage enforcement. + # The vitest.config.ts thresholds (lines 60 / functions 55 / + # branches 35 / statements 60) are enforced here — failing any + # threshold causes a non-zero exit and fails this step. + # ----------------------------------------------------------------------- + - name: Tests + coverage + run: npm --workspace=backend run test:coverage + + # ----------------------------------------------------------------------- + # 9. Build — verifies the compiled artefact can be produced cleanly. + # ----------------------------------------------------------------------- + - name: Build + run: npm --workspace=backend run build + + # ----------------------------------------------------------------------- + # 10. Upload coverage to Codecov (non-blocking — network issue must not + # fail the gate; coverage threshold is enforced by vitest above). + # ----------------------------------------------------------------------- + - name: Upload coverage to Codecov + if: always() + uses: codecov/codecov-action@v4 + with: + files: ./backend/coverage/coverage-final.json + flags: backend + token: ${{ secrets.CODECOV_TOKEN }} + continue-on-error: true + + # ----------------------------------------------------------------------- + # 11. Upload failure artefacts — coverage report and build output are + # preserved for 7 days so engineers can diagnose failures offline. + # ----------------------------------------------------------------------- + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-coverage-${{ github.run_id }} + path: backend/coverage/ + retention-days: 7 + if-no-files-found: warn + + - name: Upload build output + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-dist-${{ github.run_id }} + path: backend/dist/ + retention-days: 7 + if-no-files-found: warn + + # ----------------------------------------------------------------------- + # 12. Step summary — quick at-a-glance pass/fail for each gate. + # ----------------------------------------------------------------------- + - name: Write step summary + if: always() + run: | + { + echo "## Backend CI Quality Gates — ${{ github.ref_name }}" + echo "" + echo "| Gate | Result |" + echo "|------|--------|" + echo "| Clean install | ${{ job.status }} |" + echo "| Format check | ${{ job.status }} |" + echo "| Lint | ${{ job.status }} |" + echo "| Type check | ${{ job.status }} |" + echo "| Migrations | ${{ job.status }} |" + echo "| Tests/Coverage | ${{ job.status }} |" + echo "| Build | ${{ job.status }} |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/backend/.prettierignore b/backend/.prettierignore new file mode 100644 index 00000000..3e299880 --- /dev/null +++ b/backend/.prettierignore @@ -0,0 +1,5 @@ +dist/ +coverage/ +node_modules/ +*.json +*.md diff --git a/backend/.prettierrc.json b/backend/.prettierrc.json new file mode 100644 index 00000000..5ca088f1 --- /dev/null +++ b/backend/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false +} diff --git a/backend/package.json b/backend/package.json index 80b09da0..adaa9011 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,12 +7,15 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", + "typecheck": "tsc --noEmit", "start": "node dist/index.js", "test": "vitest run", "test:unit": "vitest run --config vitest.unit.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", "lint": "eslint src/ --ext .ts", "lint:fix": "eslint src/ --ext .ts --fix", "migrate": "tsx src/database/migrate.ts up", @@ -69,6 +72,7 @@ "@vitest/coverage-v8": "^2.1.9", "eslint": "^8.57.1", "ioredis-mock": "^8.13.1", + "prettier": "^3.9.6", "tsx": "^4.19.2", "typescript": "^5.9.3", "vitest": "^2.1.9" diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 7705bc40..78d55ccc 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -135,6 +135,9 @@ const envSchema = z.object({ */ WS_AUTH_SECRET: z.string().optional(), + // Webhook security + WEBHOOK_ENCRYPTION_KEY: z.string().min(32).optional(), + // Health Score Weights HEALTH_WEIGHT_LIQUIDITY: z.coerce.number().default(0.25), HEALTH_WEIGHT_PRICE: z.coerce.number().default(0.25), diff --git a/backend/src/services/webhook.service.ts b/backend/src/services/webhook.service.ts index afb61e3c..360931d7 100644 --- a/backend/src/services/webhook.service.ts +++ b/backend/src/services/webhook.service.ts @@ -182,6 +182,49 @@ export class WebhookService { // HMAC SIGNING // --------------------------------------------------------------------------- + private getEncryptionKey(): Buffer { + const key = config.WEBHOOK_ENCRYPTION_KEY || "default-webhook-encryption-key-change-me"; + return crypto.createHash("sha256").update(key).digest(); + } + + private encryptSecret(secret: string): string { + if (!secret) { + return secret; + } + + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", this.getEncryptionKey(), iv); + const encrypted = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`; + } + + private decryptSecret(secret: string): string { + if (!secret) { + return secret; + } + + const parts = secret.split(":"); + if (parts.length !== 3) { + return secret; + } + + try { + const [ivHex, authTagHex, encryptedHex] = parts; + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const encrypted = Buffer.from(encryptedHex, "hex"); + const decipher = crypto.createDecipheriv("aes-256-gcm", this.getEncryptionKey(), iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString("utf8"); + } catch (error) { + logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Failed to decrypt webhook secret"); + return secret; + } + } + public generateSecret(): string { return crypto.randomBytes(32).toString("hex"); } @@ -203,7 +246,7 @@ export class WebhookService { return { "Content-Type": "application/json", - "X-Webhook-Signature": signature, + "X-Bridge-Watch-Signature": signature, "X-Webhook-Timestamp": timestamp.toString(), "X-Webhook-Event-Id": crypto.randomUUID(), }; @@ -243,7 +286,7 @@ export class WebhookService { await db("webhook_endpoints") .where("id", webhookEndpointId) .update({ - secret: newSecret, + secret: this.encryptSecret(newSecret), secret_rotated_at: new Date(), updated_at: new Date(), }); @@ -308,7 +351,7 @@ export class WebhookService { url: params.url, name: params.name, description: params.description || null, - secret, + secret: this.encryptSecret(secret), is_active: true, rate_limit_per_minute: params.rateLimitPerMinute || 60, custom_headers: JSON.stringify(params.customHeaders || {}), @@ -809,7 +852,7 @@ export class WebhookService { url: row.url, name: row.name, description: row.description, - secret: row.secret, + secret: this.decryptSecret(row.secret), secretRotatedAt: row.secret_rotated_at, isActive: row.is_active, rateLimitPerMinute: row.rate_limit_per_minute, diff --git a/backend/tests/services/webhook.service.test.ts b/backend/tests/services/webhook.service.test.ts index 2d83de87..9ce06f7a 100644 --- a/backend/tests/services/webhook.service.test.ts +++ b/backend/tests/services/webhook.service.test.ts @@ -51,6 +51,7 @@ vi.mock("../../src/config/index.js", () => ({ REDIS_HOST: "localhost", REDIS_PORT: 6379, REDIS_PASSWORD: undefined, + WEBHOOK_ENCRYPTION_KEY: "test-webhook-encryption-key-32-bytes!!", }, })); @@ -126,7 +127,7 @@ describe("WebhookService — HMAC signing", () => { it("generateSignatureHeaders returns required headers", () => { const headers = service.generateSignatureHeaders('{"test":true}', "my-secret"); - expect(headers).toHaveProperty("X-Webhook-Signature"); + expect(headers).toHaveProperty("X-Bridge-Watch-Signature"); expect(headers).toHaveProperty("X-Webhook-Timestamp"); expect(headers).toHaveProperty("X-Webhook-Event-Id"); expect(headers["Content-Type"]).toBe("application/json"); @@ -182,6 +183,29 @@ describe("WebhookService — rate limiting", () => { }); }); +describe("WebhookService — secret encryption", () => { + let service: WebhookService; + + beforeEach(() => { + (WebhookService as any).instance = undefined; + service = WebhookService.getInstance(); + }); + + it("encrypts and decrypts secrets with the configured key", () => { + const secret = "super-secret-value"; + const encrypted = (service as any).encryptSecret(secret); + const decrypted = (service as any).decryptSecret(encrypted); + + expect(encrypted).toContain(":"); + expect(decrypted).toBe(secret); + }); + + it("returns plaintext values unchanged when they are not encrypted", () => { + const plaintext = "legacy-secret"; + expect((service as any).decryptSecret(plaintext)).toBe(plaintext); + }); +}); + describe("WebhookService — endpoint filtering", () => { let service: WebhookService; diff --git a/package-lock.json b/package-lock.json index 22a2042f..c0c9a446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "@vitest/coverage-v8": "^2.1.9", "eslint": "^8.57.1", "ioredis-mock": "^8.13.1", + "prettier": "^3.9.6", "tsx": "^4.19.2", "typescript": "^5.9.3", "vitest": "^2.1.9" @@ -1027,7 +1028,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -1045,7 +1045,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1063,7 +1062,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1081,7 +1079,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1099,7 +1096,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1117,7 +1113,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1135,7 +1130,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1153,7 +1147,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1171,7 +1164,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1189,7 +1181,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1207,7 +1198,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1225,7 +1215,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1243,7 +1232,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1261,7 +1249,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1279,7 +1266,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1297,7 +1283,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1315,7 +1300,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1333,7 +1317,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1351,7 +1334,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1369,7 +1351,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1387,7 +1368,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1405,7 +1385,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -1423,7 +1402,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -1441,7 +1419,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1459,7 +1436,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1477,7 +1453,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -10611,6 +10586,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",