diff --git a/.github/workflows/security-pipeline.yml b/.github/workflows/security-pipeline.yml index 6a0806e..5356f14 100644 --- a/.github/workflows/security-pipeline.yml +++ b/.github/workflows/security-pipeline.yml @@ -33,6 +33,7 @@ jobs: uses: gitleaks/gitleaks-action@b6c5701469c3e8b8f2ec5b3c81bda3f28b673af6 # v2.3.6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_CONFIG: .gitleaks.toml sast: name: SAST – Static Analysis diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..a3d5d01 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,23 @@ +[extend] +# Extend gitleaks default rules +useDefault = true + +[[rules]] +id = "stellar-secret-key" +description = "Stellar secret key (56-character key starting with S)" +regex = '''(?i)(?:stellar|xlm|horizon)[^0-9A-Za-z]{0,30}S[A-Z2-7]{55}|S[A-Z2-7]{55}(?:[^0-9A-Za-z]|$)''' +tags = ["stellar", "secret-key", "crypto"] +keywords = ["stellar", "xlm", "secret"] + +[[rules]] +id = "database-encryption-key" +description = "DATABASE_ENCRYPTION_KEY value" +regex = '''DATABASE_ENCRYPTION_KEY\s*=\s*[0-9a-fA-F]{64}''' +tags = ["key", "database"] + +[allowlist] +description = "Global allowlist for false positives" +paths = [ + ".gitleaksignore", + "CHANGELOG.md", +] diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..bb6751c --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,8 @@ +# Suppress false positives from test fixtures that use intentionally fake keys. +# Stellar testnet test vectors and clearly fake keys used only in unit tests. +# +# Format: :: +# Use `gitleaks detect --verbose` to get the exact fingerprints to add here. +# +# Example (replace with real fingerprints after running gitleaks): +# abc123:backend/tests/fixtures/stellar.js:stellar-secret-key diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8478788..0926338 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -304,3 +304,20 @@ model Session { @@index([userId]) @@index([userId, revokedAt]) } + +// Immutable audit trail for all admin actions (no UPDATE/DELETE should be issued against this table) +model AdminAuditLog { + id String @id @default(uuid()) + adminUserId String + actionType String + targetEntityType String + targetEntityId String + actionMetadata Json @default("{}") + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + + @@index([adminUserId]) + @@index([actionType]) + @@index([createdAt]) +} diff --git a/backend/src/db/adminAuditLog.js b/backend/src/db/adminAuditLog.js new file mode 100644 index 0000000..9c51e8b --- /dev/null +++ b/backend/src/db/adminAuditLog.js @@ -0,0 +1,24 @@ +import prisma from './client.js'; +import logger from '../config/logger.js'; + +/** + * Write an immutable audit log entry for an admin action. + * Failures are logged but never propagate — the action itself should not fail due to logging. + */ +export async function logAdminAction(adminId, actionType, targetType, targetId, metadata = {}, request = {}) { + try { + await prisma.adminAuditLog.create({ + data: { + adminUserId: adminId, + actionType, + targetEntityType: targetType, + targetEntityId: targetId, + actionMetadata: metadata, + ipAddress: request.ip ?? null, + userAgent: request.get ? request.get('user-agent') ?? null : null, + }, + }); + } catch (err) { + logger.error({ err, adminId, actionType, targetType, targetId }, 'Failed to write admin audit log'); + } +} diff --git a/backend/src/db/encryption.js b/backend/src/db/encryption.js new file mode 100644 index 0000000..4b08636 --- /dev/null +++ b/backend/src/db/encryption.js @@ -0,0 +1,46 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; + +function getKey() { + const raw = process.env.DATABASE_ENCRYPTION_KEY; + if (!raw) throw new Error('DATABASE_ENCRYPTION_KEY environment variable is required'); + const key = Buffer.from(raw, 'hex'); + if (key.length !== 32) throw new Error('DATABASE_ENCRYPTION_KEY must be 32 bytes (64 hex characters)'); + return key; +} + +/** + * Encrypt plaintext using AES-256-GCM. + * Returns a colon-separated string: iv:tag:ciphertext (all hex-encoded). + */ +export function encrypt(plaintext) { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${tag.toString('hex')}:${ciphertext.toString('hex')}`; +} + +/** + * Decrypt a value produced by encrypt(). + */ +export function decrypt(encryptedValue) { + const key = getKey(); + const [ivHex, tagHex, ciphertextHex] = encryptedValue.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); + const ciphertext = Buffer.from(ciphertextHex, 'hex'); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} + +/** + * Call at startup — fails fast with a clear error if the key is missing or invalid. + */ +export function validateEncryptionKey() { + getKey(); +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index ead2cc6..7f3969f 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -1,6 +1,7 @@ import express from 'express'; import prisma from '../db/client.js'; import { requireAdmin } from '../middleware/adminAuth.js'; +import { logAdminAction } from '../db/adminAuditLog.js'; const router = express.Router(); @@ -77,6 +78,7 @@ router.put('/kyc/:userId/approve', requireAdmin, async (req, res) => { where: { userId }, data: { status: 'APPROVED', updatedAt: new Date() }, }); + logAdminAction(req.user.sub, 'KYC_APPROVE', 'USER', userId, {}, req); res.json({ success: true, kyc }); } catch (error) { res.status(500).json({ error: 'Failed to approve KYC' }); @@ -90,10 +92,45 @@ router.put('/kyc/:userId/reject', requireAdmin, async (req, res) => { where: { userId }, data: { status: 'REJECTED', updatedAt: new Date() }, }); + logAdminAction(req.user.sub, 'KYC_REJECT', 'USER', userId, {}, req); res.json({ success: true, kyc }); } catch (error) { res.status(500).json({ error: 'Failed to reject KYC' }); } }); +router.get('/audit-log', requireAdmin, async (req, res) => { + try { + const { page = 1, limit = 50, adminUserId, actionType, from, to } = req.query; + const take = Math.min(parseInt(limit), 200); + const skip = (parseInt(page) - 1) * take; + + const where = {}; + if (adminUserId) where.adminUserId = adminUserId; + if (actionType) where.actionType = actionType; + if (from || to) { + where.createdAt = {}; + if (from) where.createdAt.gte = new Date(from); + if (to) where.createdAt.lte = new Date(to); + } + + const [logs, total] = await Promise.all([ + prisma.adminAuditLog.findMany({ + where, + skip, + take, + orderBy: { createdAt: 'desc' }, + }), + prisma.adminAuditLog.count({ where }), + ]); + + res.json({ + logs, + pagination: { page: parseInt(page), limit: take, total, pages: Math.ceil(total / take) }, + }); + } catch (error) { + res.status(500).json({ error: 'Failed to retrieve audit log' }); + } +}); + export default router; \ No newline at end of file diff --git a/backend/src/routes/webhooks.js b/backend/src/routes/webhooks.js index 8fdf0eb..ddbcd66 100644 --- a/backend/src/routes/webhooks.js +++ b/backend/src/routes/webhooks.js @@ -1,6 +1,7 @@ import express from 'express'; import { requireAuth } from '../middleware/auth.js'; import { registerWebhook, listWebhooks, deleteWebhook, rotateWebhookSecret, verifyWebhookSignature } from '../webhooks/store.js'; +import { webhookSignatureMiddleware } from '../webhooks/verifySignature.js'; import logger from '../config/logger.js'; const router = express.Router(); @@ -174,6 +175,17 @@ router.post('/:id/rotate-secret', requireAuth, (req, res) => { * 200: * description: Verification result */ +/** + * POST /api/v1/webhooks/incoming + * Receive and process incoming webhook payloads from external services. + * Signature is verified via HMAC-SHA256 before any processing occurs. + */ +router.post('/incoming', webhookSignatureMiddleware, (req, res) => { + logger.info({ source: req.headers['x-webhook-source'] ?? 'unknown' }, 'Incoming webhook received'); + // Dispatch to application logic based on payload type + res.status(200).json({ received: true }); +}); + router.post('/verify', (req, res) => { const { webhookId, signature, payload } = req.body; diff --git a/backend/src/server.js b/backend/src/server.js index 6c5674b..74e27a8 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -58,9 +58,18 @@ import { sanitizeInputs } from './middleware/sanitize.js'; import { startScheduler, stopScheduler } from './scheduler.js'; import { csrfTokenMiddleware, validateCSRFMiddleware, csrfTokenEndpoint } from './middleware/csrf.js'; import dotenv from 'dotenv'; +import { validateEncryptionKey } from './db/encryption.js'; dotenv.config(); +// Fail fast if the database encryption key is missing or invalid +try { + validateEncryptionKey(); +} catch (err) { + console.error(`[startup] ${err.message}`); + process.exit(1); +} + const app = express(); const PORT = getConfig().server.port; diff --git a/backend/src/webhooks/store.js b/backend/src/webhooks/store.js index 6d55b31..a8afd73 100644 --- a/backend/src/webhooks/store.js +++ b/backend/src/webhooks/store.js @@ -1,4 +1,4 @@ -import { createHmac, randomBytes } from 'crypto'; +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; import logger from '../config/logger.js'; // In-memory webhook store (replace with DB in production) @@ -60,14 +60,22 @@ export function verifyWebhookSignature(webhookId, signature, payload) { const webhook = getWebhook(webhookId); if (!webhook) return false; + const safeEqual = (a, b) => { + try { + return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); + } catch { + return false; + } + }; + // Try current secret const expectedSignature = signPayload(webhook.signingSecret, payload); - if (signature === expectedSignature) return true; + if (safeEqual(signature, expectedSignature)) return true; // Try previous secrets (for rotation grace period) for (const oldSecret of webhook.previousSecrets) { const oldSignature = signPayload(oldSecret, payload); - if (signature === oldSignature) return true; + if (safeEqual(signature, oldSignature)) return true; } return false; diff --git a/backend/src/webhooks/verifySignature.js b/backend/src/webhooks/verifySignature.js new file mode 100644 index 0000000..6236df5 --- /dev/null +++ b/backend/src/webhooks/verifySignature.js @@ -0,0 +1,44 @@ +import { createHmac, timingSafeEqual } from 'crypto'; +import logger from '../config/logger.js'; + +/** + * Verify an HMAC-SHA256 signature from an incoming webhook request. + * Uses timingSafeEqual to prevent timing attacks. + * + * @param {Buffer} rawBody - Raw request body buffer (before JSON parsing) + * @param {string} signature - Signature from the request header (hex string) + * @param {string} secret - The shared signing secret + * @returns {boolean} + */ +export function verifyHmacSignature(rawBody, signature, secret) { + if (!signature || !secret) return false; + const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); + try { + return timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex')); + } catch { + return false; + } +} + +/** + * Express middleware that verifies incoming webhook signatures. + * Reads the secret from WEBHOOK_SIGNING_SECRET env variable. + * Returns 401 on failure without revealing why. + */ +export function webhookSignatureMiddleware(req, res, next) { + const secret = process.env.WEBHOOK_SIGNING_SECRET; + if (!secret) { + logger.warn('WEBHOOK_SIGNING_SECRET not set — skipping signature verification'); + return next(); + } + + const signature = req.headers['x-webhook-signature'] ?? req.headers['x-hub-signature-256']; + const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body)); + + if (!verifyHmacSignature(rawBody, signature, secret)) { + logger.warn({ ip: req.ip, path: req.path }, 'Invalid webhook signature rejected'); + return res.status(401).json({ error: 'Unauthorized' }); + } + + next(); +}