diff --git a/data/disciplr.db b/data/disciplr.db index 85c31f24..db97c577 100644 Binary files a/data/disciplr.db and b/data/disciplr.db differ 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 e2e27fd8..4de414de 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -49,19 +49,44 @@ All environment variables are validated at startup using `src/config/env.ts`. If | `JOB_WORKER_CONCURRENCY` | 2 | Number of concurrent job workers. | | `JOB_QUEUE_POLL_INTERVAL_MS` | 250 | How often the job queue checks for new work. | | `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. | -## Docker images & healthchecks +## Structured Abuse Category Taxonomy (#467) -- Dockerfile: A multi-stage, Node 20 (alpine) image is provided at the repository root. It sets `WORKDIR /app` and runs the container as the non-root `node` user for improved security. -- Healthcheck: The `docker-compose.yml` now declares a `backend` service with a `healthcheck` that calls `/api/health`. The Postgres `db` service also has a readiness check. Compose `depends_on` is configured so `backend` will wait for `db` to be healthy. +The abuse monitor now emits structured `security.abuse_detected` events instead of free-form strings, enabling downstream aggregation by anomaly class. -Validation (recommended in CI): +### Categories -```bash -docker compose build && docker compose up --wait +| 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 + } +} ``` -Notes: -- The runtime image includes `curl` so the healthcheck can probe the HTTP endpoint. -- CI should run the `docker compose up --wait` step to ensure service health ordering behaves as expected. +### 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 08e3573d..2eb0cf32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "pino-pretty": "^10.3.1", "prisma": "^6.19.2", "supertest": "^7.2.2", - "ts-jest": "^29.4.11", + "ts-jest": "^29.4.6", "tsx": "^4.19.2", "typescript": "^5.6.3", "vitest": "^4.0.18", @@ -1140,6 +1140,68 @@ "dev": true, "license": "MIT" }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", @@ -4090,8 +4152,31 @@ "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==", - "license": "MIT" + "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", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } }, "node_modules/@prisma/client": { "version": "6.19.3", @@ -5091,6 +5176,53 @@ "node": ">=20" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "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", + "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", + "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", + "optional": true, + "peer": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5859,6 +5991,21 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -5993,6 +6140,15 @@ "node": ">=10" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/argon2": { "version": "0.44.0", "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", @@ -6049,7 +6205,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", "engines": { "node": ">=8.0.0" } @@ -7093,41 +7248,9 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, - "license": "MIT" - }, - "node_modules/create-jest/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/create-jest/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -7192,7 +7315,6 @@ "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", "dev": true, - "license": "MIT", "engines": { "node": "*" } @@ -7380,7 +7502,9 @@ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -8132,8 +8256,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -8831,8 +8954,7 @@ "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, - "license": "MIT" + "dev": true }, "node_modules/html-escaper": { "version": "2.0.2", @@ -11934,7 +12056,6 @@ "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" } @@ -13361,7 +13482,6 @@ "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==", - "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -13732,7 +13852,6 @@ "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -13754,7 +13873,6 @@ "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==", - "license": "MIT", "dependencies": { "split2": "^4.0.0" } @@ -13764,7 +13882,6 @@ "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.3.1.tgz", "integrity": "sha512-az8JbIYeN/1iLj2t0jR9DV48/LQ3RC6hZPpapKPkb84Q+yTidMCpgWxIT3N0flnBDilyBQ1luWNpOeJptjdp/g==", "dev": true, - "license": "MIT", "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", @@ -13804,7 +13921,6 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -13815,7 +13931,6 @@ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", "dev": true, - "license": "MIT", "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" @@ -13826,7 +13941,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dev": true, - "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", @@ -13843,7 +13957,6 @@ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz", "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==", "dev": true, - "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" } @@ -13851,8 +13964,7 @@ "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==", - "license": "MIT" + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" }, "node_modules/pirates": { "version": "4.0.7", @@ -14155,7 +14267,6 @@ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.6.0" } @@ -14173,8 +14284,7 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ], - "license": "MIT" + ] }, "node_modules/prom-client": { "version": "15.1.3", @@ -14336,8 +14446,7 @@ "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==", - "license": "MIT" + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, "node_modules/randombytes": { "version": "2.1.0", @@ -14495,7 +14604,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", "engines": { "node": ">= 12.13.0" } @@ -14818,7 +14926,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", "engines": { "node": ">=10" } @@ -14840,8 +14947,7 @@ "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, - "license": "BSD-3-Clause" + "dev": true }, "node_modules/semver": { "version": "6.3.1", @@ -15232,7 +15338,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" } @@ -15713,6 +15818,14 @@ "real-require": "^0.2.0" } }, + "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", "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", @@ -15933,6 +16046,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -16183,6 +16342,15 @@ "node": ">= 0.4.0" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "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", + "optional": true, + "peer": true + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -16670,6 +16838,31 @@ "node": ">=8" } }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/src/config/env.ts b/src/config/env.ts index cd73da2b..65e927be 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 c2898ae3..bcb37f67 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] @@ -119,6 +121,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 75a5a204..c361ab91 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -18,6 +18,7 @@ import { } from '../services/featureFlags.js' import { pool } from '../db/index.js' import { db } from '../db/knex.js' +import { getAbuseCategoryCounts } from '../security/abuse-monitor.js' export const adminRouter = Router() @@ -506,95 +507,10 @@ adminRouter.get('/db/metrics', metricsRateLimiter, async (req: Request, res: Res }) /** - * Get all feature flags for an organization - * GET /api/admin/flags - * Query params: ?orgId=... (optional, defaults to global flags) + * GET /api/admin/abuse/category-counts + * Returns per-category abuse event counts (brute-force, enumeration, payload-anomaly, rate-limit-trip). + * Admin only. */ -adminRouter.get('/flags', async (req: Request, res: Response) => { - try { - const orgId = typeof req.query.orgId === 'string' ? req.query.orgId : null - - const flags = await getAllFlags(orgId) - - res.status(200).json({ - data: { - orgId: orgId || 'global', - flags, - timestamp: new Date().toISOString(), - }, - }) - } catch (error) { - console.error('Error retrieving feature flags:', error) - res.status(500).json({ error: 'Failed to retrieve feature flags' }) - } -}) - -/** - * Set feature flag for an organization - * PATCH /api/admin/flags/:name - * Body: { enabled: boolean, orgId?: string } - * - * Examples: - * - Enable global flag: PATCH /api/admin/flags/ENTERPRISE_ANALYTICS { enabled: true } - * - Enable org-specific: PATCH /api/admin/flags/ENTERPRISE_ANALYTICS { enabled: true, orgId: "org-123" } - */ -adminRouter.patch('/flags/:name', async (req: Request, res: Response) => { - try { - const { name } = req.params - const { enabled, orgId } = req.body - - // Validate enabled is boolean - if (typeof enabled !== 'boolean') { - return res.status(400).json({ - error: 'Invalid request body', - details: 'enabled must be a boolean', - }) - } - - // Validate flag name exists in enum - if (!isValidFeatureFlag(name)) { - return res.status(400).json({ - error: 'Invalid feature flag name', - details: `Unknown flag: ${name}. Valid flags: ${Object.values(FeatureFlag).join(', ')}`, - }) - } - - // Validate orgId is string or undefined - if (orgId !== undefined && typeof orgId !== 'string') { - return res.status(400).json({ - error: 'Invalid request body', - details: 'orgId must be a string or omitted', - }) - } - - // Set the flag - const result = await setFlag(name, orgId || null, enabled) - - // Audit log the change - await createAuditLog({ - actor_user_id: req.user!.userId, - action: 'admin.feature_flag.update', - target_type: 'feature_flag', - target_id: `${name}:${orgId || 'global'}`, - metadata: { - flag_name: name, - org_id: orgId || null, - enabled: result, - previous_value: undefined, // Would require querying before change, can be added later - timestamp: new Date().toISOString(), - }, - }) - - res.status(200).json({ - data: { - flag: name, - orgId: orgId || 'global', - enabled: result, - timestamp: new Date().toISOString(), - }, - }) - } catch (error) { - console.error('Error updating feature flag:', error) - res.status(500).json({ error: 'Failed to update feature flag' }) - } +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 +}