From 446e8e825b4656ef3bf59018b4e1efe49abe03f2 Mon Sep 17 00:00:00 2001 From: soterika Date: Tue, 2 Jun 2026 10:43:30 +0000 Subject: [PATCH] feat: structured anomaly categories in abuse-monitor --- data/disciplr.db | Bin 32768 -> 32768 bytes docs/auth.md | 10 + docs/operations.md | 39 ++++ docs/vaults-api.md | 69 +++++++ package-lock.json | 338 ++++++++++++++++++++++++++------ src/config/env.ts | 1 + src/middleware/errorHandler.ts | 20 ++ src/routes/admin.ts | 10 + src/security/abuse-monitor.ts | 112 +++++++++-- src/services/abuse-monitor.ts | 56 ++++-- src/services/soroban.ts | 32 ++- src/tests/abuse-monitor.test.ts | 110 ++++++++++- src/tests/soroban.test.ts | 143 ++++++++++++++ src/types/security.ts | 50 +++++ 14 files changed, 888 insertions(+), 102 deletions(-) create mode 100644 src/types/security.ts diff --git a/data/disciplr.db b/data/disciplr.db index d5b82a9b1a836fb233e7b7f10649ac1867d18b72..db97c5773f0f55a12fbe7761530970eb2d61f91c 100644 GIT binary patch delta 18 ZcmZo@U}|V!+VG>E!_dIW#L&vf3;;j21=aun delta 18 ZcmZo@U}|V!+VG>E!@$zYz}(8v5CA{w1=;`r diff --git a/docs/auth.md b/docs/auth.md index f35e9f42..1abcf22d 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -369,3 +369,13 @@ The test suite includes property-based tests with minimum 100 iterations per pro ## Middleware Consolidation `auth.middleware.ts` and `userAuth.ts` have been consolidated into `auth.ts`. Please import `authenticate` and `authorize` strictly from `src/middleware/auth.js`. `requireUserAuth` is deprecated and will be removed in #454. + +## Abuse Detection and Anomaly Categories + +Failed authentication attempts are tracked by the `security/abuse-monitor.ts` middleware and emitted as structured log events with an `AbuseCategory` discriminated union. See `src/types/security.ts` for the full type definition. + +Auth-related categories: + +- **`brute-force`**: Triggered when a source IP exceeds `SECURITY_FAILED_LOGIN_BURST_THRESHOLD` failed logins within `SECURITY_FAILED_LOGIN_WINDOW_MS`. Carries `failedLoginCount` and `windowMs`. + +Aggregate counts are available at `GET /api/admin/abuse/category-counts` for admin users. diff --git a/docs/operations.md b/docs/operations.md index 4e2336e5..4de414de 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -51,3 +51,42 @@ All environment variables are validated at startup using `src/config/env.ts`. If | `JOB_HISTORY_LIMIT` | 50 | Number of completed/failed jobs to keep in memory metrics. | | `DATABASE_URL` | - | PostgreSQL connection URL. | | `JWT_SECRET` | - | Secret for signing JWTs. | + +## Structured Abuse Category Taxonomy (#467) + +The abuse monitor now emits structured `security.abuse_detected` events instead of free-form strings, enabling downstream aggregation by anomaly class. + +### Categories + +| Category | Trigger | Key fields | +|---|---|---| +| `brute-force` | `failed_login_burst` pattern | `failedLoginCount`, `windowMs` | +| `enumeration` | `endpoint_scan` pattern | `notFoundCount`, `distinctPathCount`, `windowMs` | +| `payload-anomaly` | `repeated_bad_requests` pattern | `badRequestCount`, `windowMs` | +| `rate-limit-trip` | `high_volume` pattern | `requestCount`, `windowMs` | + +### Admin endpoint + +`GET /api/admin/abuse/category-counts` (admin token required) returns a snapshot of per-category counts: + +```json +{ + "data": { + "brute-force": 3, + "enumeration": 1, + "payload-anomaly": 0, + "rate-limit-trip": 2 + } +} +``` + +### Log format + +```json +{ + "event": "security.suspicious_pattern", + "ip": "1.2.3.4", + "category": { "type": "brute-force", "failedLoginCount": 6, "windowMs": 900000 }, + "alertCooldownMs": 300000 +} +``` diff --git a/docs/vaults-api.md b/docs/vaults-api.md index 5c1b66b0..8a1d0852 100644 --- a/docs/vaults-api.md +++ b/docs/vaults-api.md @@ -259,3 +259,72 @@ The validation logic is covered by comprehensive tests including: // ... other fields } ``` + +## Soroban Transaction Polling and Timeout + +When `onChain.mode` is `"submit"`, the backend sends the transaction to the Soroban RPC and then polls `getTransaction` until the tx reaches a terminal state. + +### Polling behaviour + +- After `sendTransaction` returns `PENDING` or `TRY_AGAIN_LATER`, the backend enters a bounded poll loop. +- Each poll calls `getTransaction(hash)`. + - `NOT_FOUND` → sleep `SOROBAN_SUBMIT_POLL_INTERVAL_MS` ms and retry (up to `SOROBAN_SUBMIT_POLL_MAX_ATTEMPTS` attempts). + - `SUCCESS` → resolve with `{ txHash }`. + - `FAILED` → throw `Error("Soroban transaction did not succeed: FAILED")`. +- The entire poll window is bounded by `SOROBAN_SUBMIT_TIMEOUT_MS`. If the deadline elapses before a terminal status is reached, a `SorobanTimeoutError` is thrown. + +### Environment variables + +| Variable | Default | Description | +|---|---|---| +| `SOROBAN_SUBMIT_TIMEOUT_MS` | `60000` | Hard deadline (ms) for the entire poll window. | +| `SOROBAN_SUBMIT_POLL_INTERVAL_MS` | `1000` | Delay between individual `getTransaction` polls. | +| `SOROBAN_SUBMIT_POLL_MAX_ATTEMPTS` | `30` | Maximum number of poll attempts before giving up. | + +### SorobanTimeoutError + +`SorobanTimeoutError` is thrown (and surfaced in the submission response as `status: "error"`) when the deadline is exceeded. It carries: + +- `txHash` — the transaction hash that was being polled. +- `elapsedMs` — the configured deadline that was exceeded (`SOROBAN_SUBMIT_TIMEOUT_MS`). +- `code` — `"SOROBAN_TIMEOUT"`. +- `status` — `504`. + +Example submission response when a timeout occurs: + +```json +{ + "mode": "submit", + "payload": { "..." }, + "submission": { + "attempted": true, + "status": "error", + "error": "Soroban transaction tx-abc123 did not finalise within 60000ms" + } +} +``` + +## Soroban Transaction Polling and Timeout + +When `onChain.mode` is `"submit"`, the backend polls `getTransaction` after sending the transaction until a terminal state is reached. + +### Polling behaviour + +- After `sendTransaction` returns `PENDING`, the backend enters a bounded poll loop using `retryWithBackoff`. +- Each poll calls `getTransaction(hash)`: + - `NOT_FOUND` → sleep `SOROBAN_SUBMIT_POLL_INTERVAL_MS` ms and retry. + - `SUCCESS` → resolves with `{ txHash }`. + - `FAILED` → throws an error immediately. +- The entire poll window is bounded by `SOROBAN_SUBMIT_TIMEOUT_MS`. If the deadline elapses, a `SorobanTimeoutError` is thrown. + +### Env vars + +| Variable | Default | Description | +|---|---|---| +| `SOROBAN_SUBMIT_TIMEOUT_MS` | `60000` | Hard deadline (ms) for the whole poll window. | +| `SOROBAN_SUBMIT_POLL_INTERVAL_MS` | `1000` | Delay between `getTransaction` polls. | +| `SOROBAN_SUBMIT_POLL_MAX_ATTEMPTS` | `30` | Max poll attempts before giving up. | + +### SorobanTimeoutError + +Thrown when the deadline is exceeded. Carries `txHash`, `elapsedMs`, `code: "SOROBAN_TIMEOUT"`, `status: 504`. Surfaced in the submission response as `status: "error"`. diff --git a/package-lock.json b/package-lock.json index 9e324ae3..685678e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "helmet": "^7.2.0", "jsonwebtoken": "^9.0.3", "pg": "^8.20.0", + "pino": "^9.4.0", "prom-client": "^15.0.0", "zod": "^4.3.6" }, @@ -49,10 +50,10 @@ "fast-check": "^3.23.1", "jest": "^30.2.0", "knex": "^3.1.0", + "pino-pretty": "^10.3.1", "prisma": "^6.19.2", "supertest": "^7.2.2", "ts-jest": "^29.4.6", - "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "^5.6.3", "vitest": "^4.0.18", @@ -165,7 +166,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1058.0.tgz", "integrity": "sha512-AfED3hhaBZ121NuiBImgnlF98kQRMk6hGPMGfj/Oo1hSaoMFRzM+N4nlICCasUSM2R8QaIRZRYGpZ3fy0ilGZQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -639,7 +639,6 @@ "version": "7.29.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1095,6 +1094,8 @@ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -1108,6 +1109,8 @@ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -1171,12 +1174,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@epic-web/invariant": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", - "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", - "license": "MIT" - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -2464,7 +2461,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2686,6 +2682,11 @@ "node": ">=10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3926,28 +3927,36 @@ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", @@ -4175,7 +4184,6 @@ "version": "22.19.11", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4325,7 +4333,6 @@ "version": "8.56.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -4957,7 +4964,6 @@ "version": "8.16.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4979,6 +4985,8 @@ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "acorn": "^8.11.0" }, @@ -5000,7 +5008,6 @@ "version": "6.14.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5136,7 +5143,9 @@ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/argon2": { "version": "0.31.2", @@ -5179,6 +5188,14 @@ "version": "0.4.0", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "license": "MIT", @@ -5484,7 +5501,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6038,27 +6054,13 @@ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true, - "license": "MIT" - }, - "node_modules/cross-env": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", - "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", "license": "MIT", - "dependencies": { - "@epic-web/invariant": "^1.0.0", - "cross-spawn": "^7.0.6" - }, - "bin": { - "cross-env": "dist/bin/cross-env.js", - "cross-env-shell": "dist/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=20" - } + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -6111,6 +6113,15 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "license": "MIT", @@ -6275,6 +6286,8 @@ "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.3.1" } @@ -6512,7 +6525,6 @@ "version": "9.39.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6888,6 +6900,12 @@ "node": ">=8.0.0" } }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "dev": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -7573,6 +7591,12 @@ "node": ">=16.0.0" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -7870,6 +7894,7 @@ }, "node_modules/isexe": { "version": "2.0.0", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -7978,7 +8003,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8600,11 +8624,19 @@ "version": "2.6.1", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -9223,7 +9255,6 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -9403,17 +9434,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9644,6 +9664,14 @@ "devOptional": true, "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "license": "MIT", @@ -9845,6 +9873,7 @@ }, "node_modules/path-key": { "version": "3.1.1", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10000,6 +10029,124 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.3.1.tgz", + "integrity": "sha512-az8JbIYeN/1iLj2t0jR9DV48/LQ3RC6hZPpapKPkb84Q+yTidMCpgWxIT3N0flnBDilyBQ1luWNpOeJptjdp/g==", + "dev": true, + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.0.0", + "pump": "^3.0.0", + "readable-stream": "^4.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "dev": true, + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-pretty/node_modules/sonic-boom": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz", + "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==", + "dev": true, + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -10249,7 +10396,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.2", "@prisma/engines": "6.19.2" @@ -10279,6 +10425,30 @@ "node": ">=6" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/prom-client": { "version": "15.1.3", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", @@ -10416,6 +10586,11 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/randombytes": { "version": "2.1.0", "license": "MIT", @@ -10478,7 +10653,6 @@ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10489,7 +10663,6 @@ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10542,6 +10715,14 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/rechoir": { "version": "0.8.0", "dev": true, @@ -10863,6 +11044,14 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "license": "MIT" @@ -10874,6 +11063,12 @@ "dev": true, "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "dev": true + }, "node_modules/semver": { "version": "6.3.1", "dev": true, @@ -10987,6 +11182,7 @@ }, "node_modules/shebang-command": { "version": "2.0.0", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -10997,6 +11193,7 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11233,6 +11430,14 @@ "node": ">=8.0.0" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "dev": true, @@ -11527,7 +11732,6 @@ "integrity": "sha512-J72R4ltw0UBVUlEjTzI0gg2STOqlI9JBhQOL4Dxt7aJOnnSesy0qJDn4PYfMCafk9cWOaVg129Pesl5o+DIh0Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", @@ -11839,6 +12043,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tildify": { "version": "2.0.0", "dev": true, @@ -11895,7 +12107,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12057,6 +12268,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -12106,7 +12318,6 @@ "version": "4.21.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -12192,7 +12403,6 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12361,7 +12571,9 @@ "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -12389,7 +12601,6 @@ "version": "7.3.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12479,7 +12690,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12602,6 +12812,7 @@ }, "node_modules/which": { "version": "2.0.2", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -12983,6 +13194,8 @@ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -13001,7 +13214,6 @@ "node_modules/zod": { "version": "4.3.6", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/config/env.ts b/src/config/env.ts index f12e74cd..a82f7756 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -110,6 +110,7 @@ export const envSchema = z SOROBAN_SUBMIT_POLL_MAX_ATTEMPTS: positiveInt(30), SOROBAN_RPC_TIMEOUT_MS: positiveInt(30_000), SOROBAN_SUBMIT_RETRY_MAX_BACKOFF_MS: positiveInt(5_000), + SOROBAN_SUBMIT_TIMEOUT_MS: positiveInt(60_000), STELLAR_NETWORK_PASSPHRASE: z.string().optional(), // ── Job system ─────────────────────────────────────────────── diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index c3d3d584..5d1b3808 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -22,6 +22,8 @@ export const ErrorCode = { RATE_LIMITED: 'RATE_LIMITED', // 500 INTERNAL_ERROR: 'INTERNAL_ERROR', + // 504 + SOROBAN_TIMEOUT: 'SOROBAN_TIMEOUT', } as const export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode] @@ -118,6 +120,24 @@ export class AppError extends Error { } } +/** + * Thrown when the Soroban transaction status polling deadline is exceeded. + * Maps to HTTP 504 Gateway Timeout. + */ +export class SorobanTimeoutError extends Error { + readonly code = ErrorCode.SOROBAN_TIMEOUT + readonly status = 504 + readonly txHash: string + readonly elapsedMs: number + + constructor(txHash: string, elapsedMs: number) { + super(`Soroban transaction ${txHash} did not finalise within ${elapsedMs}ms`) + this.name = 'SorobanTimeoutError' + this.txHash = txHash + this.elapsedMs = elapsedMs + } +} + // ─── Express error-handler middleware ──────────────────────────────────────── // Must have the 4-argument signature so Express recognises it as an error handler. export const errorHandler = ( diff --git a/src/routes/admin.ts b/src/routes/admin.ts index b3b570aa..b06e928c 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -11,6 +11,7 @@ import { cancelVaultById } from '../services/vaultStore.js' import { getDBHealthMetrics } from '../services/dbMetrics.js' import { pool } from '../db/index.js' import { db } from '../db/knex.js' +import { getAbuseCategoryCounts } from '../security/abuse-monitor.js' export const adminRouter = Router() @@ -497,3 +498,12 @@ adminRouter.get('/db/metrics', metricsRateLimiter, async (req: Request, res: Res res.status(500).json({ error: 'Failed to retrieve database metrics' }) } }) + +/** + * GET /api/admin/abuse/category-counts + * Returns per-category abuse event counts (brute-force, enumeration, payload-anomaly, rate-limit-trip). + * Admin only. + */ +adminRouter.get('/abuse/category-counts', authorize, requireAdmin, (req: Request, res: Response) => { + res.status(200).json({ data: getAbuseCategoryCounts() }) +}) diff --git a/src/security/abuse-monitor.ts b/src/security/abuse-monitor.ts index 2593eaac..12f38701 100644 --- a/src/security/abuse-monitor.ts +++ b/src/security/abuse-monitor.ts @@ -1,4 +1,6 @@ import type { NextFunction, Request, Response } from 'express' +import type { AbuseCategory } from '../types/security.js' +import { logger } from '../middleware/logger.js' type SuspiciousPatternType = | 'endpoint_scan' @@ -83,8 +85,7 @@ export function securityMetricsMiddleware( if (isFailedLoginAttempt(path, status)) { state.failedLoginTimes.push(now) metrics.failedLoginAttempts += 1 - logSecurityEvent('security.failed_login_attempt', { - ip, + logSecurityEvent('security.failed_login_attempt', ip, null, { path, method: req.method, status, @@ -116,12 +117,13 @@ export function securityRateLimitMiddleware( if (state.requestTimes.length > config.rateLimitMaxRequests) { metrics.rateLimitTriggers += 1 - logSecurityEvent('security.rate_limit_triggered', { - ip, - path: sanitizePath(req.originalUrl), - method: req.method, + logSecurityEvent('security.rate_limit_triggered', ip, { + type: 'rate-limit-trip', requestCount: state.requestTimes.length, windowMs: config.rateLimitWindowMs, + }, { + path: sanitizePath(req.originalUrl), + method: req.method, threshold: config.rateLimitMaxRequests, }) res.status(429).json({ error: 'Too many requests' }) @@ -181,6 +183,19 @@ export function getSecurityMetricsSnapshot(): Record { } } +/** + * Returns per-category abuse event counts keyed by AbuseCategory type. + * Maps internal SuspiciousPatternType names to the structured taxonomy. + */ +export function getAbuseCategoryCounts(): Record { + return { + 'brute-force': metrics.suspiciousPatterns.failed_login_burst, + 'enumeration': metrics.suspiciousPatterns.endpoint_scan, + 'payload-anomaly': metrics.suspiciousPatterns.repeated_bad_requests, + 'rate-limit-trip': metrics.suspiciousPatterns.high_volume, + } +} + export function __resetSecurityMonitorForTests(): void { // Test-only reset hook for the module-level in-memory counters and IP state. ipStates.clear() @@ -281,14 +296,44 @@ function emitSuspiciousAlert( state.lastAlertAt[pattern] = now metrics.suspiciousPatterns[pattern] += 1 - logSecurityEvent('security.suspicious_pattern', { - ip, - pattern, + const category = buildAbuseCategory(pattern, details) + + logSecurityEvent('security.suspicious_pattern', ip, category, { alertCooldownMs: config.alertCooldownMs, ...details, }) } +function buildAbuseCategory(pattern: SuspiciousPatternType, details: Record): AbuseCategory { + switch (pattern) { + case 'failed_login_burst': + return { + type: 'brute-force', + failedLoginCount: details.currentValue ?? 0, + windowMs: details.windowMs ?? config.failedLoginWindowMs, + } + case 'endpoint_scan': + return { + type: 'enumeration', + notFoundCount: details.current404Count ?? 0, + distinctPathCount: details.distinctPathCount ?? 0, + windowMs: details.windowMs ?? config.suspiciousWindowMs, + } + case 'repeated_bad_requests': + return { + type: 'payload-anomaly', + badRequestCount: details.currentValue ?? 0, + windowMs: details.windowMs ?? config.suspiciousWindowMs, + } + case 'high_volume': + return { + type: 'rate-limit-trip', + requestCount: details.currentValue ?? 0, + windowMs: details.windowMs ?? config.suspiciousWindowMs, + } + } +} + function maybeCleanupIdleIpStates(now: number): void { processedEvents += 1 if (processedEvents % 200 !== 0) { @@ -337,16 +382,45 @@ function sanitizePath(path: string): string { return sanitized || '/' } -function logSecurityEvent(event: string, data: Record): void { - console.log( - JSON.stringify({ - level: 'warn', - event, - service: 'disciplr-backend', - timestamp: new Date().toISOString(), - ...data, - }), - ) +function logSecurityEvent(event: string, ip: string, category: AbuseCategory | null, data: Record): void { + // Use the centralized Pino logger for structured, redacted output. + // Attach the event, ip and optional category as top-level keys to facilitate aggregation. + const payload: Record = { + event, + ip, + ...data, + } + + if (category !== null) { + payload.category = category + } + + // pino will handle timestamps and redaction based on configuration + logger.warn(payload) +} + +/** + * Test helper: emit a structured suspicious event immediately (increments internal counters). + * Exposed to tests so we can assert structured pino output without exercising Express flows. + */ +export function emitTestSuspiciousEvent(ip: string, category: AbuseCategory, extra: Record = {}): void { + // Map category.type back to the internal suspiciousPatterns counters + switch (category.type) { + case 'brute-force': + metrics.suspiciousPatterns.failed_login_burst += 1 + break + case 'enumeration': + metrics.suspiciousPatterns.endpoint_scan += 1 + break + case 'payload-anomaly': + metrics.suspiciousPatterns.repeated_bad_requests += 1 + break + case 'rate-limit-trip': + metrics.suspiciousPatterns.high_volume += 1 + break + } + + logSecurityEvent('security.suspicious_pattern', ip, category, extra) } function readPositiveIntEnv(name: string, fallback: number): number { diff --git a/src/services/abuse-monitor.ts b/src/services/abuse-monitor.ts index d796f190..6336cb2a 100644 --- a/src/services/abuse-monitor.ts +++ b/src/services/abuse-monitor.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import type { AbuseCategory, AbuseEvent } from '../types/security.js' /** * Configuration for the Abuse Monitor Heuristics. @@ -15,15 +16,20 @@ export interface AbuseSignal { readonly id: string // Raw IP or UserID (will be sanitized) readonly weight?: number // Importance of the signal (default: 1) readonly type: 'request' | 'auth_fail' | 'invalid_xdr' + /** Optional structured category for enriched event emission. */ + readonly category?: AbuseCategory } /** * AbuseMonitor: Tracks and evaluates behavior signals to identify malicious actors. * Designed to reduce false positives via a weighted scoring system and confidence decay. + * Emits structured AbuseEvent objects for downstream aggregation. */ export class AbuseMonitor { private readonly scores: Map = new Map() private readonly config: AbuseMonitorConfig + /** Running totals per category type for the admin summary endpoint. */ + private readonly categoryCounts: Record = {} constructor(config?: Partial) { this.config = { @@ -36,16 +42,16 @@ export class AbuseMonitor { } /** - * Record an activity signal. + * Record an activity signal. * @returns boolean - True if the actor should be throttled/blocked. */ public record(signal: AbuseSignal): boolean { const sanitizedId = this.sanitizeIdentifier(signal.id) const now = Date.now() - + // Prevent Map exhaustion if (!this.scores.has(sanitizedId) && this.scores.size >= (this.config.maxEntries ?? 10000)) { - return false + return false } const record = this.scores.get(sanitizedId) || { score: 0, lastSeen: now } @@ -64,14 +70,19 @@ export class AbuseMonitor { const isAbusive = currentScore >= this.config.penaltyScoreLimit if (isAbusive) { - this.logAbuse(sanitizedId, currentScore, signal.type) + this.emitAbuseEvent(sanitizedId, currentScore, signal) } return isAbusive } + /** Returns a copy of the per-category abuse event counts. */ + public getCategoryCounts(): Record { + return { ...this.categoryCounts } + } + /** - * Detirministic PII Masking. + * Deterministic PII Masking. * Replaces sensitive identifiers with an opaque token. */ private sanitizeIdentifier(id: string): string { @@ -81,17 +92,38 @@ export class AbuseMonitor { .substring(0, 12) } - private logAbuse(hashedId: string, score: number, type: string): void { - // Structured logging without PII leakage + private emitAbuseEvent(hashedId: string, score: number, signal: AbuseSignal): void { + const category: AbuseCategory = signal.category ?? this.inferCategory(signal) + const categoryType = category.type + + this.categoryCounts[categoryType] = (this.categoryCounts[categoryType] ?? 0) + 1 + + const event: AbuseEvent = { + event: 'security.abuse_detected', + actorHash: hashedId, + category, + timestamp: new Date().toISOString(), + } + + // Structured log — no PII console.warn(JSON.stringify({ - event: 'ABUSE_LIMIT_REACHED', - actor_hash: hashedId, + ...event, confidence_score: Math.floor(score), - trigger_type: type, - timestamp: new Date().toISOString() })) } + /** Infer a default AbuseCategory from the signal type when none is provided. */ + private inferCategory(signal: AbuseSignal): AbuseCategory { + switch (signal.type) { + case 'auth_fail': + return { type: 'brute-force', failedLoginCount: 1, windowMs: 0 } + case 'invalid_xdr': + return { type: 'payload-anomaly', badRequestCount: 1, windowMs: 0 } + default: + return { type: 'rate-limit-trip', requestCount: 1, windowMs: 0 } + } + } + /** * Clean up old records to prevent memory leaks. */ @@ -104,4 +136,4 @@ export class AbuseMonitor { } } } -} \ No newline at end of file +} diff --git a/src/services/soroban.ts b/src/services/soroban.ts index 1c922aaf..21058d60 100644 --- a/src/services/soroban.ts +++ b/src/services/soroban.ts @@ -1,5 +1,6 @@ import type { CreateVaultInput, PersistedVault, VaultCreateResponse } from '../types/vaults.js' import { retryWithBackoff, sleep, type RetryConfig } from '../utils/retry.js' +import { AppError, SorobanTimeoutError } from '../middleware/errorHandler.js' const DEFAULT_CONTRACT_ID = 'CONTRACT_ID_NOT_CONFIGURED' const DEFAULT_SOURCE_ACCOUNT = 'SOURCE_ACCOUNT_NOT_CONFIGURED' @@ -23,6 +24,7 @@ export interface SorobanConfig { submitPollIntervalMs: number submitPollMaxAttempts: number rpcTimeoutMs: number + submitTimeoutMs: number submitRetry: RetryConfig } @@ -66,6 +68,7 @@ export const getSorobanConfig = (): SorobanConfig | null => { submitPollIntervalMs: positiveIntFromEnv('SOROBAN_SUBMIT_POLL_INTERVAL_MS', DEFAULT_SUBMIT_POLL_INTERVAL_MS), submitPollMaxAttempts: positiveIntFromEnv('SOROBAN_SUBMIT_POLL_MAX_ATTEMPTS', DEFAULT_SUBMIT_POLL_MAX_ATTEMPTS), rpcTimeoutMs: positiveIntFromEnv('SOROBAN_RPC_TIMEOUT_MS', DEFAULT_RPC_TIMEOUT_MS), + submitTimeoutMs: positiveIntFromEnv('SOROBAN_SUBMIT_TIMEOUT_MS', 60_000), submitRetry: getSubmitRetryConfig(), } } @@ -120,15 +123,30 @@ async function submitTransaction( throw new Error(`Soroban sendTransaction failed: ${response.status}`) } - let getResponse = await server.getTransaction(response.hash) - const maxAttempts = 30 - let attempts = 0 - while (getResponse.status === 'NOT_FOUND' && attempts < maxAttempts) { - await new Promise((r) => setTimeout(r, 1000)) - getResponse = await server.getTransaction(response.hash) - attempts++ + const deadline = Date.now() + config.submitTimeoutMs + const pollConfig: RetryConfig = { + maxAttempts: config.submitPollMaxAttempts, + initialBackoffMs: config.submitPollIntervalMs, + maxBackoffMs: config.submitPollIntervalMs, + backoffMultiplier: 1, + jitterFactor: 0, } + let getResponse = await retryWithBackoff( + async () => { + if (Date.now() >= deadline) { + throw new SorobanTimeoutError(response.hash, config.submitTimeoutMs) + } + const result = await server.getTransaction(response.hash) + if (result.status === 'NOT_FOUND') { + throw Object.assign(new Error('transaction_pending'), { retryable: true }) + } + return result + }, + pollConfig, + (err) => !!(err as any).retryable, + ) + if (getResponse.status !== 'SUCCESS') { throw new Error(`Soroban transaction did not succeed: ${getResponse.status}`) } diff --git a/src/tests/abuse-monitor.test.ts b/src/tests/abuse-monitor.test.ts index d95d5147..5d52e767 100644 --- a/src/tests/abuse-monitor.test.ts +++ b/src/tests/abuse-monitor.test.ts @@ -1,5 +1,8 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals' import { AbuseMonitor } from '../services/abuse-monitor.js' +import { logger } from '../middleware/logger.js' +import { emitTestSuspiciousEvent, __resetSecurityMonitorForTests, getAbuseCategoryCounts } from '../security/abuse-monitor.js' +import type { AbuseCategory } from '../types/security.js' describe('AbuseMonitor Heuristics', () => { let monitor: AbuseMonitor @@ -29,7 +32,7 @@ describe('AbuseMonitor Heuristics', () => { const logOutput = consoleSpy.mock.calls[0][0] expect(logOutput).not.toContain(pii) - expect(logOutput).toContain('actor_hash') + expect(logOutput).toContain('actorHash') consoleSpy.mockRestore() }) @@ -83,4 +86,109 @@ describe('AbuseMonitor Heuristics', () => { const result = smallMonitor.record({ id: 'user3', type: 'request', weight: 200 }) expect(result).toBe(false) }) +}) + +describe('AbuseMonitor structured AbuseCategory events (#467)', () => { + let monitor: AbuseMonitor + + beforeEach(() => { + monitor = new AbuseMonitor({ penaltyScoreLimit: 10, decayRate: 0 }) + }) + + it('emits structured AbuseEvent JSON with category when limit is exceeded', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + monitor.record({ id: 'ip1', type: 'auth_fail', weight: 20 }) + + expect(warnSpy).toHaveBeenCalledTimes(1) + const logged = JSON.parse(warnSpy.mock.calls[0][0] as string) + expect(logged.event).toBe('security.abuse_detected') + expect(logged.category).toBeDefined() + expect(logged.actorHash).toBeDefined() + expect(logged.timestamp).toBeDefined() + expect(logged.actorHash).not.toContain('ip1') + warnSpy.mockRestore() + }) + + it('infers brute-force category for auth_fail signals', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + monitor.record({ id: 'ip2', type: 'auth_fail', weight: 20 }) + const logged = JSON.parse(warnSpy.mock.calls[0][0] as string) + expect(logged.category.type).toBe('brute-force') + warnSpy.mockRestore() + }) + + it('infers payload-anomaly category for invalid_xdr signals', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + monitor.record({ id: 'ip3', type: 'invalid_xdr', weight: 20 }) + const logged = JSON.parse(warnSpy.mock.calls[0][0] as string) + expect(logged.category.type).toBe('payload-anomaly') + warnSpy.mockRestore() + }) + + it('infers rate-limit-trip category for generic request signals', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + monitor.record({ id: 'ip4', type: 'request', weight: 20 }) + const logged = JSON.parse(warnSpy.mock.calls[0][0] as string) + expect(logged.category.type).toBe('rate-limit-trip') + warnSpy.mockRestore() + }) + + it('uses an explicitly passed AbuseCategory over the inferred one', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const explicit: AbuseCategory = { type: 'enumeration', notFoundCount: 25, distinctPathCount: 15, windowMs: 300000 } + monitor.record({ id: 'ip5', type: 'request', weight: 20, category: explicit }) + const logged = JSON.parse(warnSpy.mock.calls[0][0] as string) + expect(logged.category).toEqual(explicit) + warnSpy.mockRestore() + }) + + it('increments getCategoryCounts for each distinct category', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) + monitor.record({ id: 'a1', type: 'auth_fail', weight: 20 }) + monitor.record({ id: 'a2', type: 'auth_fail', weight: 20 }) + monitor.record({ id: 'b1', type: 'invalid_xdr', weight: 20 }) + + const counts = monitor.getCategoryCounts() + expect(counts['brute-force']).toBe(2) + expect(counts['payload-anomaly']).toBe(1) + jest.restoreAllMocks() + }) + + it('getCategoryCounts returns a copy (mutations do not affect internal state)', () => { + const counts = monitor.getCategoryCounts() + counts['brute-force'] = 999 + expect(monitor.getCategoryCounts()['brute-force']).not.toBe(999) + }) +}) + +describe('security/abuse-monitor structured events (pino integration)', () => { + beforeEach(() => { + __resetSecurityMonitorForTests() + }) + + it('emits structured pino warn payload with category when invoked via test helper', () => { + const spy = jest.spyOn(logger, 'warn').mockImplementation(() => {}) + + const category = { type: 'enumeration', notFoundCount: 12, distinctPathCount: 8, windowMs: 300000 } + emitTestSuspiciousEvent('1.2.3.4', category as any, { alertCooldownMs: 300000 }) + + expect(spy).toHaveBeenCalledTimes(1) + const payload = spy.mock.calls[0][0] + expect(payload).toBeDefined() + expect(payload.event).toBe('security.suspicious_pattern') + expect(payload.ip).toBe('1.2.3.4') + expect(payload.category).toEqual(category) + + spy.mockRestore() + }) + + it('getAbuseCategoryCounts reflects emitted events', () => { + const category = { type: 'brute-force', failedLoginCount: 3, windowMs: 900000 } + emitTestSuspiciousEvent('2.2.2.2', category as any) + emitTestSuspiciousEvent('3.3.3.3', category as any) + + const counts = getAbuseCategoryCounts() + expect(counts['brute-force']).toBe(2) + }) }) \ No newline at end of file diff --git a/src/tests/soroban.test.ts b/src/tests/soroban.test.ts index 7af5a3e7..53603851 100644 --- a/src/tests/soroban.test.ts +++ b/src/tests/soroban.test.ts @@ -19,6 +19,7 @@ import { submitClaim, submitWithdraw, } from '../services/soroban.js' +import { SorobanTimeoutError } from '../middleware/errorHandler.js' // ─── Fixtures ──────────────────────────────────────────────────────────────── @@ -1071,4 +1072,146 @@ it('returns correct default sourceAccount when env is not set', async () => { expect(serialized).not.toContain('SCZANGBA') }) }) + + // ─── Deadline-bounded polling (#435) ────────────────────────────────────── + + describe('deadline-bounded polling (SOROBAN_SUBMIT_TIMEOUT_MS)', () => { + const makeTimeoutConfig = (overrides: Partial> = {}): SorobanConfig => { + setEnv({ + ...FULL_ENV, + RETRY_MAX_ATTEMPTS: '1', + RETRY_BACKOFF_MS: '1', + SOROBAN_SUBMIT_POLL_INTERVAL_MS: '1', + SOROBAN_SUBMIT_POLL_MAX_ATTEMPTS: '3', + SOROBAN_RPC_TIMEOUT_MS: '5000', + SOROBAN_SUBMIT_RETRY_MAX_BACKOFF_MS: '2', + SOROBAN_SUBMIT_TIMEOUT_MS: '5000', + ...overrides, + }) + const config = getSorobanConfig() + expect(config).not.toBeNull() + return config! + } + + it('resolves immediately when getTransaction returns SUCCESS on first poll', async () => { + makeTimeoutConfig() + const { client } = createMockClient({ txHash: 'tx-immediate' }) + setSorobanClient(client) + const input = makeInput({ onChain: { mode: 'submit' } }) + const result = await buildVaultCreationPayload(input, makeVault()) + expect(result.submission.status).toBe('success') + expect(result.submission.txHash).toBe('tx-immediate') + }) + + it('surfaces error status when submission rejects', async () => { + makeTimeoutConfig() + const { client } = createMockClient(undefined, new Error('poll-failure')) + setSorobanClient(client) + const input = makeInput({ onChain: { mode: 'submit' } }) + const result = await buildVaultCreationPayload(input, makeVault()) + expect(result.submission.status).toBe('error') + }) + + it('throws when getTransaction returns FAILED status via defaultSorobanClient', async () => { + const makeFakeSdk = (server: Record) => ({ + Keypair: { fromSecret: jest.fn(() => ({ sign: jest.fn(), publicKey: jest.fn() })) }, + Contract: class { call = jest.fn(() => ({})) }, + rpc: { Server: jest.fn(() => server) }, + TransactionBuilder: class { + addOperation = jest.fn(() => this); setTimeout = jest.fn(() => this); build = jest.fn(() => ({})) + }, + nativeToScVal: jest.fn((v: unknown) => v), + BASE_FEE: '100', + }) + + const server = { + getAccount: jest.fn().mockResolvedValue({}), + prepareTransaction: jest.fn().mockResolvedValue({ sign: jest.fn() }), + sendTransaction: jest.fn().mockResolvedValue({ status: 'PENDING', hash: 'tx-failed' }), + getTransaction: jest.fn().mockResolvedValue({ status: 'FAILED' }), + } + const client = createDefaultSorobanClient(async () => makeFakeSdk(server)) + const cfg = makeTimeoutConfig() + + await expect(client.submitVaultCreation(cfg, makeVault() as any)) + .rejects.toThrow('Soroban transaction did not succeed: FAILED') + }) + + it('throws SorobanTimeoutError when deadline is exceeded during polling', async () => { + const makeFakeSdk = (server: Record) => ({ + Keypair: { fromSecret: jest.fn(() => ({ sign: jest.fn(), publicKey: jest.fn() })) }, + Contract: class { call = jest.fn(() => ({})) }, + rpc: { Server: jest.fn(() => server) }, + TransactionBuilder: class { + addOperation = jest.fn(() => this); setTimeout = jest.fn(() => this); build = jest.fn(() => ({})) + }, + nativeToScVal: jest.fn((v: unknown) => v), + BASE_FEE: '100', + }) + + const server = { + getAccount: jest.fn().mockResolvedValue({}), + prepareTransaction: jest.fn().mockResolvedValue({ sign: jest.fn() }), + sendTransaction: jest.fn().mockResolvedValue({ status: 'PENDING', hash: 'tx-timeout' }), + getTransaction: jest.fn().mockResolvedValue({ status: 'NOT_FOUND' }), + } + const client = createDefaultSorobanClient(async () => makeFakeSdk(server)) + const cfg = makeTimeoutConfig({ SOROBAN_SUBMIT_TIMEOUT_MS: '1', SOROBAN_SUBMIT_POLL_MAX_ATTEMPTS: '5' }) + await new Promise((r) => setTimeout(r, 5)) + await expect(client.submitVaultCreation(cfg, makeVault() as any)) + .rejects.toBeInstanceOf(SorobanTimeoutError) + }) + + it('SorobanTimeoutError carries txHash, elapsedMs, code, and status', async () => { + const hash = 'tx-timeout-meta' + const makeFakeSdk = (server: Record) => ({ + Keypair: { fromSecret: jest.fn(() => ({ sign: jest.fn(), publicKey: jest.fn() })) }, + Contract: class { call = jest.fn(() => ({})) }, + rpc: { Server: jest.fn(() => server) }, + TransactionBuilder: class { + addOperation = jest.fn(() => this); setTimeout = jest.fn(() => this); build = jest.fn(() => ({})) + }, + nativeToScVal: jest.fn((v: unknown) => v), + BASE_FEE: '100', + }) + + const server = { + getAccount: jest.fn().mockResolvedValue({}), + prepareTransaction: jest.fn().mockResolvedValue({ sign: jest.fn() }), + sendTransaction: jest.fn().mockResolvedValue({ status: 'PENDING', hash }), + getTransaction: jest.fn().mockResolvedValue({ status: 'NOT_FOUND' }), + } + const client = createDefaultSorobanClient(async () => makeFakeSdk(server)) + const cfg = makeTimeoutConfig({ SOROBAN_SUBMIT_TIMEOUT_MS: '1', SOROBAN_SUBMIT_POLL_MAX_ATTEMPTS: '5' }) + await new Promise((r) => setTimeout(r, 5)) + + let caught: unknown + try { + await client.submitVaultCreation(cfg, makeVault() as any) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(SorobanTimeoutError) + const err = caught as SorobanTimeoutError + expect(err.txHash).toBe(hash) + expect(err.elapsedMs).toBe(1) + expect(err.code).toBe('SOROBAN_TIMEOUT') + expect(err.status).toBe(504) + }) + + it('SOROBAN_SUBMIT_TIMEOUT_MS is read from env correctly', () => { + setEnv({ ...FULL_ENV, SOROBAN_SUBMIT_TIMEOUT_MS: '45000' }) + const config = getSorobanConfig() + expect(config).not.toBeNull() + expect(config!.submitTimeoutMs).toBe(45000) + }) + + it('submitTimeoutMs defaults to 60000 when env var is absent', () => { + setEnv(FULL_ENV) + const config = getSorobanConfig() + expect(config).not.toBeNull() + expect(config!.submitTimeoutMs).toBe(60000) + }) + }) }) diff --git a/src/types/security.ts b/src/types/security.ts new file mode 100644 index 00000000..a158be05 --- /dev/null +++ b/src/types/security.ts @@ -0,0 +1,50 @@ +/** + * Structured anomaly category taxonomy for abuse detection events. + * Each variant carries the fields relevant to its detection logic. + */ + +export type AbuseCategoryType = + | 'brute-force' + | 'enumeration' + | 'payload-anomaly' + | 'rate-limit-trip' + +export type BruteForceCategory = { + readonly type: 'brute-force' + readonly failedLoginCount: number + readonly windowMs: number +} + +export type EnumerationCategory = { + readonly type: 'enumeration' + readonly notFoundCount: number + readonly distinctPathCount: number + readonly windowMs: number +} + +export type PayloadAnomalyCategory = { + readonly type: 'payload-anomaly' + readonly badRequestCount: number + readonly windowMs: number +} + +export type RateLimitTripCategory = { + readonly type: 'rate-limit-trip' + readonly requestCount: number + readonly windowMs: number +} + +/** Discriminated union over all structured anomaly categories. */ +export type AbuseCategory = + | BruteForceCategory + | EnumerationCategory + | PayloadAnomalyCategory + | RateLimitTripCategory + +/** Structured abuse event emitted to the log and returned by the snapshot. */ +export interface AbuseEvent { + readonly event: 'security.abuse_detected' + readonly actorHash: string + readonly category: AbuseCategory + readonly timestamp: string +}