diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 69a65af4..0fd7456f 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -11,14 +11,14 @@ jobs: strategy: matrix: - node-version: [20] + node-version: [22] steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} @@ -27,7 +27,8 @@ jobs: run: npm install --legacy-peer-deps - name: Run backend unit tests - run: npm test --prefix stellar-payment-platform + working-directory: stellar-payment-platform + run: npm test - name: Run memory leak detection tests (#511) working-directory: stellar-payment-platform diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cd4b71e..13cedf77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,4 @@ name: Production Safety Checks - on: push: branches: [ "main" ] @@ -46,16 +45,16 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' cache-dependency-path: ./payment-dashboard/package-lock.json - + - name: Install Frontend Dependencies run: npm install --legacy-peer-deps - + - name: Verify Vite Build (Catches Vercel Crashes) - run: npm run build \ No newline at end of file + run: npm run build diff --git a/.github/workflows/rust-security.yml b/.github/workflows/rust-security.yml index f68dd0b0..cde00b64 100644 --- a/.github/workflows/rust-security.yml +++ b/.github/workflows/rust-security.yml @@ -1,11 +1,9 @@ name: Rust Security Checks - on: push: branches: [ main ] pull_request: branches: [ main ] - jobs: security: name: Static security analysis @@ -13,35 +11,29 @@ jobs: defaults: run: working-directory: ./payment_router - env: - RUSTFLAGS: "-D warnings" steps: - name: Checkout - uses: actions/checkout@v4 - + uses: actions/checkout@v5 - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt - - name: Verify formatting run: | cargo fmt --all -- --check - - name: Run clippy (deny warnings) run: | cargo clippy --all-targets --all-features -- -D warnings - - name: Install and run cargo-dylint run: | cargo install cargo-dylint --force || true - cargo dylint --all -- -D warnings + cargo dylint --all --all-targets --all-features -- -D warnings - name: Install and run cargo-audit run: | cargo install cargo-audit --force || true cargo audit --ignore RUSTSEC-2024-0344 --ignore RUSTSEC-2026-0009 --ignore RUSTSEC-2025-0056 --ignore RUSTSEC-2024-0436 --ignore RUSTSEC-2026-0097 - - name: Run tests + - name: Run tests (release) run: | cargo test --all \ No newline at end of file diff --git a/.github/workflows/soroban.yml b/.github/workflows/soroban.yml index 4e5fe42b..f1901191 100644 --- a/.github/workflows/soroban.yml +++ b/.github/workflows/soroban.yml @@ -1,11 +1,8 @@ -name: Soroban Contract CI - -on: +name: Soroban Contract CI on: push: branches: [main] pull_request: branches: [main] - jobs: contract-checks: name: Contract Build & Test @@ -15,23 +12,21 @@ jobs: working-directory: ./payment_router steps: - name: Checkout Code - uses: actions/checkout@v4 - + uses: actions/checkout@v5 - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown - - name: Cache cargo registry and build artifacts uses: Swatinem/rust-cache@v2 with: workspaces: ./payment_router - - name: Run contract tests - run: cargo test --verbose - + run: cargo test --all-features --verbose + - name: Anomaly Detection + run: cargo test --all-features -- --ignored - name: Build contract to WASM - run: cargo build --target wasm32-unknown-unknown --release + run: cargo build --all-features --target wasm32-unknown-unknown --release coverage: name: Coverage Report (cargo tarpaulin) @@ -67,4 +62,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: tarpaulin-coverage-report - path: payment_router/coverage/ + path: payment_router/coverage/ \ No newline at end of file diff --git a/stellar-payment-platform/.env.example b/stellar-payment-platform/.env.example index a0a40099..e777d7e3 100644 --- a/stellar-payment-platform/.env.example +++ b/stellar-payment-platform/.env.example @@ -62,6 +62,21 @@ ADMIN_API_KEY="your-secure-admin-api-key-here" # CORS_ALLOWED_ORIGINS="https://example.com,https://staging.example.com" # VITE_API_BASE="https://api.yourdomain.com" +# --- Anomaly Monitor ---------------------------------------------------------- +# Cron expression controlling how often the anomaly detection sweep runs. +# Defaults to every 15 minutes. +# ANOMALY_CRON="*/15 * * * *" + +# Slack webhook URL to receive anomaly alerts. Left unset, no Slack alert is sent. +# ANOMALY_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." + +# Email webhook URL to receive anomaly alerts. Left unset, no email alert is sent. +# ANOMALY_EMAIL_WEBHOOK_URL="https://example.com/email-webhook" + +# When "true", accounts flagged by the anomaly monitor are automatically +# soft-blocked (flaggedAt set). Defaults to disabled. +# ANOMALY_AUTO_PAUSE="false" + # --- JWT (RS256) -------------------------------------------------------------- # RSA key pair for signing and verifying JWTs. # Generate with: diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index 50e150b0..4f1094d5 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -10,6 +10,7 @@ const { prisma, isPrismaConnectionError } = require('./prismaClient'); const { scheduleCleanupJob } = require('./src/cleanup-cron'); const { scheduleSoftDeletePurgeJob } = require('./src/soft-delete-purge-cron'); const { schedulePoolMonitoring } = require('./src/db-pool-monitor'); +const { scheduleAnomalyMonitor } = require('./src/scripts/anomalyMonitor'); const { correlationId } = require('./middleware/correlation'); const { idempotencyMiddleware } = require('./middleware/idempotency'); const Filter = require('bad-words'); @@ -266,6 +267,7 @@ app.use(compression({ threshold: 1024 })); scheduleCleanupJob(prisma); scheduleSoftDeletePurgeJob(prisma); const poolMonitor = schedulePoolMonitoring(prisma); +scheduleAnomalyMonitor(prisma); const RESERVED_USERNAMES = [ 'admin', diff --git a/stellar-payment-platform/src/scripts/anomalyMonitor.js b/stellar-payment-platform/src/scripts/anomalyMonitor.js new file mode 100644 index 00000000..a4f31834 --- /dev/null +++ b/stellar-payment-platform/src/scripts/anomalyMonitor.js @@ -0,0 +1,253 @@ +'use strict'; + +/** + * src/scripts/anomalyMonitor.js + * + * Detects unusually large transaction volumes for accounts and dispatches + * alerts. An account is flagged when its transaction volume in the current + * sliding window exceeds 1000% (10x) of its historical daily average volume. + * + * Alerts are dispatched to Slack and/or Email via configurable webhooks. + * Optionally, flagged accounts can be auto-paused (soft-blocked) when + * ANOMALY_AUTO_PAUSE is enabled. + */ + +const cron = require('node-cron'); +const { logger } = require('../logger'); + +/** Accounts whose current-window volume exceeds this multiple of their daily + * average are considered anomalous. 1000% == 10x. */ +const ANOMALY_THRESHOLD_MULTIPLIER = 10; + +/** Length of the sliding window used to compare against the daily average. */ +const WINDOW_HOURS = 24; + +/** Number of days of history used to compute the daily average volume. */ +const AVERAGE_LOOKBACK_DAYS = 30; + +/** Minimum number of historical days required before an average is meaningful. */ +const MIN_HISTORY_DAYS = 3; + +/** + * Computes the daily average volume (sum of amounts) for a given account + * address over the lookback period, excluding the current window. + * + * @param {import('@prisma/client').PrismaClient} prisma + * @param {string} address - Account address (from or to). + * @param {Date} windowStart - Start of the current sliding window. + * @returns {Promise} Average daily volume in the lookback period. + */ +async function getDailyAverageVolume(prisma, address, windowStart) { + const lookbackStart = new Date(windowStart); + lookbackStart.setDate(lookbackStart.getDate() - AVERAGE_LOOKBACK_DAYS); + + const rows = await prisma.paymentIntent.findMany({ + where: { + OR: [{ from: address }, { to: address }], + createdAt: { gte: lookbackStart, lt: windowStart }, + }, + select: { amount: true, createdAt: true }, + }); + + if (rows.length === 0) return 0; + + // Group by calendar day to compute per-day totals. + const dayTotals = new Map(); + for (const row of rows) { + const day = row.createdAt.toISOString().slice(0, 10); + const amount = Number(row.amount) || 0; + dayTotals.set(day, (dayTotals.get(day) || 0) + amount); + } + + const activeDays = dayTotals.size; + if (activeDays < MIN_HISTORY_DAYS) return 0; + + const total = [...dayTotals.values()].reduce((sum, v) => sum + v, 0); + return total / activeDays; +} + +/** + * Computes the transaction volume for an account within the current sliding + * window. + * + * @param {import('@prisma/client').PrismaClient} prisma + * @param {string} address - Account address (from or to). + * @param {Date} windowStart - Start of the current sliding window. + * @returns {Promise} Total volume in the window. + */ +async function getWindowVolume(prisma, address, windowStart) { + const rows = await prisma.paymentIntent.findMany({ + where: { + OR: [{ from: address }, { to: address }], + createdAt: { gte: windowStart }, + }, + select: { amount: true }, + }); + + return rows.reduce((sum, row) => sum + (Number(row.amount) || 0), 0); +} + +/** + * Dispatches an anomaly alert to configured Slack and/or Email webhooks. + * No-op when no webhook URLs are configured. + * + * @param {object} anomaly - { address, windowVolume, dailyAverage, ratio } + */ +async function dispatchAlert(anomaly) { + const { address, windowVolume, dailyAverage, ratio } = anomaly; + const message = { + text: `[Anomaly Monitor] Unusually large transaction volume detected for account ${address}. ` + + `Window volume: ${windowVolume}, daily average: ${dailyAverage}, ratio: ${ratio.toFixed(2)}x.`, + }; + + const slackUrl = process.env.ANOMALY_SLACK_WEBHOOK_URL; + if (slackUrl) { + try { + const res = await fetch(slackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message), + }); + if (!res.ok) { + logger.error(`[anomaly-monitor] Slack alert failed with status ${res.status}`); + } + } catch (err) { + logger.error('[anomaly-monitor] Slack alert dispatch error:', err.message); + } + } + + const emailUrl = process.env.ANOMALY_EMAIL_WEBHOOK_URL; + if (emailUrl) { + try { + const res = await fetch(emailUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subject: `[Anomaly Monitor] Unusual volume for ${address}`, + body: message.text, + }), + }); + if (!res.ok) { + logger.error(`[anomaly-monitor] Email alert failed with status ${res.status}`); + } + } catch (err) { + logger.error('[anomaly-monitor] Email alert dispatch error:', err.message); + } + } + + if (!slackUrl && !emailUrl) { + logger.warn( + `[anomaly-monitor] Anomaly detected for ${address} but no alert channel configured ` + + '(set ANOMALY_SLACK_WEBHOOK_URL or ANOMALY_EMAIL_WEBHOOK_URL).', + ); + } +} + +/** + * Optionally auto-pauses (soft-blocks) a flagged account by setting its + * flaggedAt timestamp, mirroring the admin block endpoint behaviour. + * + * @param {import('@prisma/client').PrismaClient} prisma + * @param {string} address - Account address to pause. + */ +async function autoPauseAccount(prisma, address) { + try { + await prisma.user.updateMany({ + where: { address, flaggedAt: null }, + data: { flaggedAt: new Date() }, + }); + logger.warn(`[anomaly-monitor] Auto-paused account ${address}`); + } catch (err) { + logger.error(`[anomaly-monitor] Failed to auto-pause account ${address}:`, err.message); + } +} + +/** + * Runs the anomaly detection heuristic against the provided Prisma client. + * Exported separately so it can be unit-tested without a live cron scheduler. + * + * @param {import('@prisma/client').PrismaClient} prisma + * @returns {Promise>} List of detected anomalies. + */ +async function runAnomalyMonitor(prisma) { + const windowStart = new Date(); + windowStart.setHours(windowStart.getHours() - WINDOW_HOURS); + + // Collect all distinct account addresses involved in transactions within the + // current window. + const windowRows = await prisma.paymentIntent.findMany({ + where: { createdAt: { gte: windowStart } }, + select: { from: true, to: true }, + }); + + const addresses = new Set(); + for (const row of windowRows) { + addresses.add(row.from); + addresses.add(row.to); + } + + const anomalies = []; + + for (const address of addresses) { + const [windowVolume, dailyAverage] = await Promise.all([ + getWindowVolume(prisma, address, windowStart), + getDailyAverageVolume(prisma, address, windowStart), + ]); + + if (dailyAverage <= 0) continue; + + const ratio = windowVolume / dailyAverage; + if (ratio >= ANOMALY_THRESHOLD_MULTIPLIER) { + const anomaly = { address, windowVolume, dailyAverage, ratio }; + anomalies.push(anomaly); + await dispatchAlert(anomaly); + + if (process.env.ANOMALY_AUTO_PAUSE === 'true') { + await autoPauseAccount(prisma, address); + } + } + } + + return anomalies; +} + +/** + * Registers a cron job that runs the anomaly monitor on a configurable + * interval (default: every 15 minutes). + * + * @param {import('@prisma/client').PrismaClient} prisma + */ +function scheduleAnomalyMonitor(prisma) { + const expression = process.env.ANOMALY_CRON || '*/15 * * * *'; + cron.schedule(expression, async () => { + logger.info('[anomaly-monitor] Starting anomaly detection sweep…'); + try { + const anomalies = await runAnomalyMonitor(prisma); + if (anomalies.length > 0) { + logger.warn( + `[anomaly-monitor] Detected ${anomalies.length} anomalous account(s):`, + anomalies.map((a) => a.address), + ); + } else { + logger.info('[anomaly-monitor] No anomalies detected.'); + } + } catch (err) { + logger.error('[anomaly-monitor] Anomaly detection sweep failed:', err.message); + } + }); + + logger.info(`[anomaly-monitor] Anomaly detection job scheduled (${expression}).`); +} + +module.exports = { + scheduleAnomalyMonitor, + runAnomalyMonitor, + getDailyAverageVolume, + getWindowVolume, + dispatchAlert, + autoPauseAccount, + ANOMALY_THRESHOLD_MULTIPLIER, + WINDOW_HOURS, + AVERAGE_LOOKBACK_DAYS, + MIN_HISTORY_DAYS, +}; diff --git a/stellar-payment-platform/tests/anomaly-monitor.test.js b/stellar-payment-platform/tests/anomaly-monitor.test.js new file mode 100644 index 00000000..a2a00d93 --- /dev/null +++ b/stellar-payment-platform/tests/anomaly-monitor.test.js @@ -0,0 +1,177 @@ +jest.mock('node-cron', () => ({ + schedule: jest.fn(), +})); + +const cron = require('node-cron'); +const { + runAnomalyMonitor, + scheduleAnomalyMonitor, + getDailyAverageVolume, + getWindowVolume, + ANOMALY_THRESHOLD_MULTIPLIER, + WINDOW_HOURS, +} = require('../src/scripts/anomalyMonitor'); + +describe('anomaly-monitor', () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.ANOMALY_SLACK_WEBHOOK_URL; + delete process.env.ANOMALY_EMAIL_WEBHOOK_URL; + delete process.env.ANOMALY_AUTO_PAUSE; + }); + + describe('getWindowVolume', () => { + it('sums amounts for an account within the window', async () => { + const prisma = { + paymentIntent: { + findMany: jest.fn().mockResolvedValue([ + { amount: '100' }, + { amount: '50' }, + { amount: '25' }, + ]), + }, + }; + + const volume = await getWindowVolume(prisma, 'GABC', new Date()); + expect(volume).toBe(175); + expect(prisma.paymentIntent.findMany).toHaveBeenCalledTimes(1); + }); + }); + + describe('getDailyAverageVolume', () => { + it('returns 0 when there is insufficient history', async () => { + const prisma = { + paymentIntent: { + findMany: jest.fn().mockResolvedValue([]), + }, + }; + + const avg = await getDailyAverageVolume(prisma, 'GABC', new Date()); + expect(avg).toBe(0); + }); + + it('returns 0 when fewer than MIN_HISTORY_DAYS active days exist', async () => { + const now = new Date(); + const prisma = { + paymentIntent: { + findMany: jest.fn().mockResolvedValue([ + { amount: '100', createdAt: now }, + { amount: '50', createdAt: now }, + ]), + }, + }; + + const avg = await getDailyAverageVolume(prisma, 'GABC', new Date()); + expect(avg).toBe(0); + }); + + it('computes average over active days', async () => { + const day1 = new Date('2024-01-01T10:00:00Z'); + const day2 = new Date('2024-01-02T10:00:00Z'); + const day3 = new Date('2024-01-03T10:00:00Z'); + const prisma = { + paymentIntent: { + findMany: jest.fn().mockResolvedValue([ + { amount: '100', createdAt: day1 }, + { amount: '100', createdAt: day1 }, + { amount: '200', createdAt: day2 }, + { amount: '300', createdAt: day3 }, + ]), + }, + }; + + // day1=200, day2=200, day3=300 → avg = 700/3 ≈ 233.33 + const avg = await getDailyAverageVolume(prisma, 'GABC', new Date('2024-01-10')); + expect(avg).toBeCloseTo(233.33, 1); + }); + }); + + describe('runAnomalyMonitor', () => { + it('flags accounts exceeding the threshold and dispatches alerts', async () => { + const now = new Date(); + const windowStart = new Date(now); + windowStart.setHours(windowStart.getHours() - WINDOW_HOURS); + + const prisma = { + paymentIntent: { + findMany: jest.fn().mockImplementation(({ where }) => { + if (where.createdAt.gte) { + return [ + { from: 'GABC', to: 'GDEF', amount: '1000' }, + { from: 'GABC', to: 'GDEF', amount: '1000' }, + ]; + } + return [ + { amount: '10', createdAt: new Date('2024-01-01T10:00:00Z') }, + { amount: '10', createdAt: new Date('2024-01-02T10:00:00Z') }, + { amount: '10', createdAt: new Date('2024-01-03T10:00:00Z') }, + ]; + }), + }, + }; + + const anomalies = await runAnomalyMonitor(prisma); + + // Window volume for GABC = 2000, daily average = 10 → ratio 200x ≥ 10x + expect(anomalies.length).toBeGreaterThan(0); + expect(anomalies[0].address).toBe('GABC'); + expect(anomalies[0].ratio).toBeGreaterThanOrEqual(ANOMALY_THRESHOLD_MULTIPLIER); + }); + + it('does not flag accounts within normal volume', async () => { + const prisma = { + paymentIntent: { + findMany: jest.fn().mockImplementation(({ where }) => { + if (where.createdAt.gte) { + return [{ from: 'GABC', to: 'GDEF', amount: '10' }]; + } + return [ + { amount: '10', createdAt: new Date('2024-01-01T10:00:00Z') }, + { amount: '10', createdAt: new Date('2024-01-02T10:00:00Z') }, + { amount: '10', createdAt: new Date('2024-01-03T10:00:00Z') }, + ]; + }), + }, + user: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }; + + const anomalies = await runAnomalyMonitor(prisma); + expect(anomalies).toEqual([]); + }); + + it('auto-pauses flagged accounts when enabled', async () => { + process.env.ANOMALY_AUTO_PAUSE = 'true'; + + const prisma = { + paymentIntent: { + findMany: jest.fn().mockImplementation(({ where }) => { + if (where.createdAt.gte) { + return [{ from: 'GABC', to: 'GDEF', amount: '1000' }]; + } + return [ + { amount: '10', createdAt: new Date('2024-01-01T10:00:00Z') }, + { amount: '10', createdAt: new Date('2024-01-02T10:00:00Z') }, + { amount: '10', createdAt: new Date('2024-01-03T10:00:00Z') }, + ]; + }), + }, + user: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + + await runAnomalyMonitor(prisma); + expect(prisma.user.updateMany).toHaveBeenCalled(); + }); + }); + + describe('scheduleAnomalyMonitor', () => { + it('registers a cron job', () => { + const prisma = { paymentIntent: { findMany: jest.fn() }, user: { updateMany: jest.fn() } }; + scheduleAnomalyMonitor(prisma); + expect(cron.schedule).toHaveBeenCalledWith('*/15 * * * *', expect.any(Function)); + }); + }); +});