Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/security-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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",
]
8 changes: 8 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -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: <commit-hash>:<file-path>:<rule-id>
# 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
17 changes: 17 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
24 changes: 24 additions & 0 deletions backend/src/db/adminAuditLog.js
Original file line number Diff line number Diff line change
@@ -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');
}
}
46 changes: 46 additions & 0 deletions backend/src/db/encryption.js
Original file line number Diff line number Diff line change
@@ -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();
}
37 changes: 37 additions & 0 deletions backend/src/routes/admin.js
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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' });
Expand All @@ -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;
12 changes: 12 additions & 0 deletions backend/src/routes/webhooks.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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;

Expand Down
9 changes: 9 additions & 0 deletions backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
14 changes: 11 additions & 3 deletions backend/src/webhooks/store.js
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions backend/src/webhooks/verifySignature.js
Original file line number Diff line number Diff line change
@@ -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();
}