From 9d8d680c0d590139769dc143cdd89ca3e55cc0ac Mon Sep 17 00:00:00 2001 From: Musa Khalid Date: Sat, 27 Jun 2026 23:54:10 +0100 Subject: [PATCH 1/3] feat: db pool metrics (#883) + bounded threat cache (#882) #883: expose db_pool_active_connections, db_pool_idle_connections, db_pool_waiting_requests, db_pool_max_connections, db_pool_utilization on /metrics; add 80% utilization Prometheus alert rule; poll every 15s. #882: replace unbounded Map in ThreatDetectionService with LRUCache capped at 50k entries, 15-min TTL, rate-limited eviction warning. Add unit test verifying LRU eviction at the cap boundary. --- infra/monitoring/alerts.yml | 10 ++ package-lock.json | 60 +++------- package.json | 1 + .../metrics/db-pool-metrics.collector.spec.ts | 25 ++++- .../metrics/db-pool-metrics.collector.ts | 37 +++--- .../metrics/metrics-collection.service.ts | 30 +++-- .../threats/threat-detection.service.spec.ts | 106 ++++++++++++++++++ .../threats/threat-detection.service.ts | 65 ++++++++++- 8 files changed, 264 insertions(+), 70 deletions(-) create mode 100644 src/security/threats/threat-detection.service.spec.ts diff --git a/infra/monitoring/alerts.yml b/infra/monitoring/alerts.yml index 1b53451b..0c3e54f4 100644 --- a/infra/monitoring/alerts.yml +++ b/infra/monitoring/alerts.yml @@ -155,6 +155,16 @@ groups: summary: 'DB connection pool >90% utilised for 5m' description: 'Pool utilisation is {{ $value | humanizePercentage }}. New requests may queue or fail.' + - alert: DBPoolUtilizationHigh + expr: db_pool_utilization > 0.8 + for: 5m + labels: + severity: warning + service: teachlink-backend + annotations: + summary: 'DB connection pool utilization above 80% for 5m' + description: 'Pool utilization is {{ $value | humanizePercentage }}. Consider raising DATABASE_POOL_MAX or investigating slow queries.' + - alert: DBQueryLatencyHigh expr: | histogram_quantile( diff --git a/package-lock.json b/package-lock.json index f31d3a7a..8509ef75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "@opentelemetry/propagator-jaeger": "^2.0.1", "@opentelemetry/resources": "^2.7.1", "@opentelemetry/sdk-node": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.41.1", "@segment/analytics-node": "^2.1.2", "@types/csurf": "^1.11.5", "@types/express-session": "^1.18.2", @@ -83,6 +84,7 @@ "ioredis": "^5.9.3", "joi": "^18.1.2", "jwks-rsa": "^4.0.1", + "lru-cache": "^11.0.0", "multer": "^2.0.1", "murmurhash-js": "^1.0.0", "nodemailer": "^8.0.9", @@ -395,15 +397,6 @@ "@apollo/server": "^4.0.0" } }, - "node_modules/@apollo/server/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@apollo/server/node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -478,15 +471,6 @@ "node": ">=20" } }, - "node_modules/@apollo/utils.keyvaluecache/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@apollo/utils.logger": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-3.0.0.tgz", @@ -5409,16 +5393,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@openapitools/openapi-generator-cli/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@openapitools/openapi-generator-cli/node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -20682,13 +20656,12 @@ "license": "Apache-2.0" }, "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" + "node": "20 || >=22" } }, "node_modules/lru-memoizer": { @@ -20701,15 +20674,6 @@ "lru-cache": "^11.0.1" } }, - "node_modules/lru-memoizer/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -22526,6 +22490,16 @@ "node": ">= 20" } }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", diff --git a/package.json b/package.json index c07c1d55..de3a426e 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "helmet": "^8.0.0", "ioredis": "^5.9.3", "joi": "^18.1.2", + "lru-cache": "^11.0.0", "jwks-rsa": "^4.0.1", "multer": "^2.0.1", "murmurhash-js": "^1.0.0", diff --git a/src/monitoring/metrics/db-pool-metrics.collector.spec.ts b/src/monitoring/metrics/db-pool-metrics.collector.spec.ts index 2d2e7ff4..b9afa4fa 100644 --- a/src/monitoring/metrics/db-pool-metrics.collector.spec.ts +++ b/src/monitoring/metrics/db-pool-metrics.collector.spec.ts @@ -60,9 +60,30 @@ describe('DbPoolMetricsCollector', () => { const metricsStr = await metricsService.getMetrics(); expect(metricsStr).toContain('db_pool_size 10'); - expect(metricsStr).toContain('db_active_connections 6'); // totalCount(10) - idleCount(4) + expect(metricsStr).toContain('db_pool_active_connections 6'); // totalCount(10) - idleCount(4) expect(metricsStr).toContain('db_pool_idle_connections 4'); - expect(metricsStr).toContain('db_pool_pending_requests 2'); + expect(metricsStr).toContain('db_pool_waiting_requests 2'); + }); + + it('should expose the configured max connections as a gauge', async () => { + collector.collectPoolMetrics(); + const metricsStr = await metricsService.getMetrics(); + // Default DATABASE_POOL_MAX is 30 per pool.config.ts + expect(metricsStr).toMatch(/db_pool_max_connections 30/); + }); + + it('should expose pool utilisation as a ratio in [0, 1]', async () => { + // With totalCount=10 and default max=30: util = 10/30 ≈ 0.3333 + collector.collectPoolMetrics(); + let metricsStr = await metricsService.getMetrics(); + expect(metricsStr).toMatch(/db_pool_utilization 0.2/); + + // Saturate the pool: totalCount=30, max=30 => util=1 + mockPgPool.totalCount = 30; + mockPgPool.idleCount = 0; + collector.collectPoolMetrics(); + metricsStr = await metricsService.getMetrics(); + expect(metricsStr).toMatch(/db_pool_utilization 1/); }); it('should wrap pgPool.connect and track wait metrics', async () => { diff --git a/src/monitoring/metrics/db-pool-metrics.collector.ts b/src/monitoring/metrics/db-pool-metrics.collector.ts index 6c88edd9..1f6660c9 100644 --- a/src/monitoring/metrics/db-pool-metrics.collector.ts +++ b/src/monitoring/metrics/db-pool-metrics.collector.ts @@ -8,17 +8,15 @@ import { resolvePoolConfig } from '../../database/pool'; /** * Database Pool Metrics Collector * - * Runs on a 10-second cron schedule and pushes TypeORM / pg connection pool - * statistics into Prometheus gauges and counters defined in - * `MetricsCollectionService`. + * Polls the TypeORM / pg connection pool every 15 seconds and pushes + * statistics into Prometheus gauges defined in `MetricsCollectionService`. * - * Exposed metrics: - * - `db_pool_size` – Total pool slots (active + idle) - * - `db_pool_active_connections` – Currently checked-out connections - * - `db_pool_idle_connections` – Idle / available connections - * - `db_pool_pending_requests` – Requests waiting for a free slot - * - `db_pool_connections_acquired_total` – Monotonically increasing acquire counter - * - `db_pool_connections_released_total` – Monotonically increasing release counter + * Exposed metrics (per spec for issue #883): + * - `db_pool_active_connections` – Currently checked-out connections + * - `db_pool_idle_connections` – Idle / available connections + * - `db_pool_waiting_requests` – Requests waiting for a free slot + * - `db_pool_max_connections` – Configured maximum pool capacity + * - `db_pool_utilization` – Ratio active/max in [0, 1] (for alerting) * * The underlying `pg` driver exposes pool internals via the non-standard * `driver.pool` property on the TypeORM DataSource. We access it through a @@ -36,7 +34,7 @@ export class DbPoolMetricsCollector implements OnModuleInit { ) {} onModuleInit(): void { - this.logger.log('DbPoolMetricsCollector initialised – will poll pool stats every 10 s'); + this.logger.log('DbPoolMetricsCollector initialised – will poll pool stats every 15 s'); // Collect an initial snapshot immediately this.collectPoolMetrics(); this.setupPoolEventListeners(); @@ -123,27 +121,34 @@ export class DbPoolMetricsCollector implements OnModuleInit { /** * Scheduled job – polls pool statistics every 15 seconds. */ - @Cron(CronExpression.EVERY_10_SECONDS) + @Cron('*/15 * * * * *') collectPoolMetrics(): void { try { const pool = this.getPool(); if (!pool) { - return; // DataSource not yet initialised or using an unsupported driver + // Even when pool is unavailable, expose the configured max so dashboards + // do not show a stale or missing value. + this.metricsCollectionService.dbPoolMaxConnections.set(this.config.max); + return; } const totalCount: number = pool.totalCount ?? 0; const idleCount: number = pool.idleCount ?? 0; const waitingCount: number = pool.waitingCount ?? 0; const activeCount = totalCount - idleCount; + const max = this.config.max; + const utilization = max > 0 ? activeCount / max : 0; // Update gauges this.metricsCollectionService.dbPoolSize.set(totalCount); - this.metricsCollectionService.activeConnections.set(activeCount); + this.metricsCollectionService.dbPoolActiveConnections.set(activeCount); this.metricsCollectionService.dbPoolIdleConnections.set(idleCount); - this.metricsCollectionService.dbPoolPendingRequests.set(waitingCount); + this.metricsCollectionService.dbPoolWaitingRequests.set(waitingCount); + this.metricsCollectionService.dbPoolMaxConnections.set(max); + this.metricsCollectionService.dbPoolUtilization.set(utilization); this.logger.debug( - `Pool snapshot – total=${totalCount} active=${activeCount} idle=${idleCount} waiting=${waitingCount}`, + `Pool snapshot – total=${totalCount} active=${activeCount} idle=${idleCount} waiting=${waitingCount} util=${(utilization * 100).toFixed(1)}%`, ); } catch (err) { this.logger.warn( diff --git a/src/monitoring/metrics/metrics-collection.service.ts b/src/monitoring/metrics/metrics-collection.service.ts index f37f692a..a8e3ea90 100644 --- a/src/monitoring/metrics/metrics-collection.service.ts +++ b/src/monitoring/metrics/metrics-collection.service.ts @@ -25,7 +25,7 @@ export class MetricsCollectionService implements OnModuleInit { // ── Infrastructure – Database ───────────────────────────────────────────── public dbQueryDuration: Histogram; - public activeConnections: Gauge; + public dbPoolActiveConnections: Gauge; /** Total DB pool connections acquired since startup */ public dbPoolConnectionsAcquired: Counter; @@ -33,10 +33,14 @@ export class MetricsCollectionService implements OnModuleInit { public dbPoolConnectionsReleased: Counter; /** Current DB connection pool size (active + idle) */ public dbPoolSize: Gauge; + /** Configured maximum DB connection pool capacity */ + public dbPoolMaxConnections: Gauge; + /** Current pool utilisation as a ratio in [0, 1] */ + public dbPoolUtilization: Gauge; /** Currently idle / available pool connections */ public dbPoolIdleConnections: Gauge; /** Requests queued waiting for a free pool slot */ - public dbPoolPendingRequests: Gauge; + public dbPoolWaitingRequests: Gauge; /** Total number of DB pool connections that had to wait since startup */ public dbPoolWaitCount: Counter; /** Duration of database connection checkout waiting in seconds */ @@ -249,9 +253,9 @@ export class MetricsCollectionService implements OnModuleInit { }); // Database – connections - this.activeConnections = new Gauge({ - name: 'db_active_connections', - help: 'Number of currently active database connections', + this.dbPoolActiveConnections = new Gauge({ + name: 'db_pool_active_connections', + help: 'Number of currently active (checked-out) connections in the DB pool', registers: [this.registry], }); @@ -273,14 +277,26 @@ export class MetricsCollectionService implements OnModuleInit { registers: [this.registry], }); + this.dbPoolMaxConnections = new Gauge({ + name: 'db_pool_max_connections', + help: 'Configured maximum DB connection pool capacity', + registers: [this.registry], + }); + + this.dbPoolUtilization = new Gauge({ + name: 'db_pool_utilization', + help: 'Current DB pool utilisation as a ratio in [0, 1] (active / max)', + registers: [this.registry], + }); + this.dbPoolIdleConnections = new Gauge({ name: 'db_pool_idle_connections', help: 'Number of idle (available) connections in the DB pool', registers: [this.registry], }); - this.dbPoolPendingRequests = new Gauge({ - name: 'db_pool_pending_requests', + this.dbPoolWaitingRequests = new Gauge({ + name: 'db_pool_waiting_requests', help: 'Number of requests waiting for a free DB pool connection', registers: [this.registry], }); diff --git a/src/security/threats/threat-detection.service.spec.ts b/src/security/threats/threat-detection.service.spec.ts new file mode 100644 index 00000000..4b102af6 --- /dev/null +++ b/src/security/threats/threat-detection.service.spec.ts @@ -0,0 +1,106 @@ +import { ThreatDetectionService } from './threat-detection.service'; + +/** + * Helper: build a string IP for index `n` so we can deterministically + * know which entry should be the "oldest" (first inserted). + */ +function ipFor(index: number): string { + return `10.0.${Math.floor(index / 256) % 256}.${index % 256}`; +} + +describe('ThreatDetectionService', () => { + describe('behaviour (preserves existing semantics)', () => { + it('does not throw before the failure threshold is reached', () => { + const svc = new ThreatDetectionService({ max: 100, ttlMs: 60_000 }); + const ip = '192.168.0.1'; + + // 11 attempts is still allowed (attempts > 10 means 11+ throws) + for (let i = 0; i < 10; i++) svc.recordFailure(ip); + expect(() => svc.analyzeRequest(ip)).not.toThrow(); + }); + + it('throws ForbiddenOperationException once attempts exceed 10', () => { + const svc = new ThreatDetectionService({ max: 100, ttlMs: 60_000 }); + const ip = '192.168.0.2'; + + for (let i = 0; i < 11; i++) svc.recordFailure(ip); + expect(() => svc.analyzeRequest(ip)).toThrow(/Suspicious activity detected/); + }); + + it('clears the failure counter on reset()', () => { + const svc = new ThreatDetectionService({ max: 100, ttlMs: 60_000 }); + const ip = '192.168.0.3'; + + for (let i = 0; i < 11; i++) svc.recordFailure(ip); + expect(() => svc.analyzeRequest(ip)).toThrow(); + + svc.reset(ip); + expect(() => svc.analyzeRequest(ip)).not.toThrow(); + expect(svc.has(ip)).toBe(false); + }); + }); + + describe('bounded cap (issue #882 acceptance criterion: 50k max)', () => { + it('caps the cache at the configured maximum entries', () => { + const cap = 50_000; + const svc = new ThreatDetectionService({ max: cap, ttlMs: 60 * 60 * 1000 }); + + for (let i = 0; i < cap + 1; i++) { + svc.recordFailure(ipFor(i)); + } + + // Bounded at exactly the cap (spec: "Map size is bounded at 50,000 entries") + expect(svc.getCacheSize()).toBe(cap); + }); + + it('evicts the oldest entry when inserting the (cap+1)-th entry', () => { + const cap = 50_000; + const svc = new ThreatDetectionService({ max: cap, ttlMs: 60 * 60 * 1000 }); + + const firstIp = ipFor(0); + + // Fill to capacity + for (let i = 0; i < cap; i++) { + svc.recordFailure(ipFor(i)); + } + expect(svc.has(firstIp)).toBe(true); + + // The (cap+1)-th insertion triggers LRU eviction; the oldest entry + // (the first one we inserted) should be gone. + svc.recordFailure(ipFor(cap)); + + expect(svc.has(firstIp)).toBe(false); + expect(svc.getCacheSize()).toBe(cap); + }); + + it('uses the documented 50,000 entry cap when no options are provided', () => { + const svc = new ThreatDetectionService(); + expect(svc.getCacheSize()).toBe(0); + // Sanity: the default must match the documented value. + expect(ThreatDetectionService.MAX_ENTRIES).toBe(50_000); + }); + }); + + describe('TTL (issue #882 acceptance criterion: 15-minute expiry)', () => { + it('expires entries after the configured TTL has elapsed', async () => { + // Tiny TTL keeps the test fast while still exercising the same code path. + const ttlMs = 30; + const svc = new ThreatDetectionService({ max: 100, ttlMs }); + + const ip = '192.168.0.42'; + for (let i = 0; i < 11; i++) svc.recordFailure(ip); + expect(() => svc.analyzeRequest(ip)).toThrow(); + + // Wait past the TTL so the entry is reaped. + await new Promise((resolve) => setTimeout(resolve, ttlMs + 50)); + + // After expiry the entry is gone — analyseRequest should not throw. + expect(() => svc.analyzeRequest(ip)).not.toThrow(); + expect(svc.has(ip)).toBe(false); + }); + + it('uses a 15-minute TTL by default', () => { + expect(ThreatDetectionService.TTL_MS).toBe(15 * 60 * 1000); + }); + }); +}); diff --git a/src/security/threats/threat-detection.service.ts b/src/security/threats/threat-detection.service.ts index 5023dad6..fdd97bff 100644 --- a/src/security/threats/threat-detection.service.ts +++ b/src/security/threats/threat-detection.service.ts @@ -1,23 +1,84 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; +import { LRUCache } from 'lru-cache'; import { ForbiddenOperationException } from '../../common/exceptions/app.exceptions'; /** * Provides threat Detection operations. + * + * Tracks per-IP failed attempt counts in a bounded LRU cache so that + * IP-rotation attacks or large user bases cannot cause the heap to grow + * unbounded (see issue #882). + * + * The cache is capped at `MAX_ENTRIES` entries and each entry expires + * `TTL_MS` after it was last written. Eviction of an entry (whether due + * to the cap or TTL expiry) emits a single warning log so operators can + * detect sustained pressure on the structure. */ @Injectable() export class ThreatDetectionService { - private failedAttempts = new Map(); + /** Max number of tracked IPs. Tuned for memory-bounded operation. */ + static readonly MAX_ENTRIES = 50_000; + /** 15-minute TTL on each entry. */ + static readonly TTL_MS = 15 * 60 * 1000; + + private readonly logger = new Logger(ThreatDetectionService.name); + private readonly failedAttempts: LRUCache; + private lastEvictionWarnAt = 0; + + constructor( + @Optional() options?: { + max?: number; + ttlMs?: number; + }, + ) { + const max = options?.max ?? ThreatDetectionService.MAX_ENTRIES; + const ttl = options?.ttlMs ?? ThreatDetectionService.TTL_MS; + + // `lru-cache` v11 fires the `dispose` callback once per eviction. We + // rate-limit the warn log to once per 60 s so a flood of evictions does + // not amplify the very load we are trying to detect. + this.failedAttempts = new LRUCache({ + max, + ttl, + ttlAutopurge: true, + updateAgeOnGet: false, + dispose: (_value, _key, reason) => { + if (reason !== 'evict') return; + const now = Date.now(); + if (now - this.lastEvictionWarnAt < 60_000) return; + this.lastEvictionWarnAt = now; + this.logger.warn( + `LRU eviction triggered on failedAttempts cache (cap=${max}). ` + + `Sustained pressure indicates a potential IP-rotation attack; ` + + `consider raising MAX_ENTRIES or migrating to a distributed store.`, + ); + }, + }); + } + analyzeRequest(ip: string): void { const attempts = this.failedAttempts.get(ip) || 0; if (attempts > 10) { throw new ForbiddenOperationException('Suspicious activity detected'); } } + recordFailure(ip: string): void { const attempts = this.failedAttempts.get(ip) || 0; this.failedAttempts.set(ip, attempts + 1); } + reset(ip: string): void { this.failedAttempts.delete(ip); } + + /** Test introspection helper — not used by production callers. */ + getCacheSize(): number { + return this.failedAttempts.size; + } + + /** Test introspection helper — checks for presence in the bounded cache. */ + has(ip: string): boolean { + return this.failedAttempts.has(ip); + } } From 7ddbcccbf64fce828b20c94e194f7dc49ef8e2fa Mon Sep 17 00:00:00 2001 From: Musa Khalid Date: Sun, 28 Jun 2026 00:04:17 +0100 Subject: [PATCH 2/3] fix(lint): remove unused CronExpression import and apply prettier formatting --- src/monitoring/metrics/db-pool-metrics.collector.ts | 2 +- src/security/threats/threat-detection.service.ts | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/monitoring/metrics/db-pool-metrics.collector.ts b/src/monitoring/metrics/db-pool-metrics.collector.ts index 1f6660c9..097b4578 100644 --- a/src/monitoring/metrics/db-pool-metrics.collector.ts +++ b/src/monitoring/metrics/db-pool-metrics.collector.ts @@ -1,5 +1,5 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; +import { Cron } from '@nestjs/schedule'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { MetricsCollectionService } from './metrics-collection.service'; diff --git a/src/security/threats/threat-detection.service.ts b/src/security/threats/threat-detection.service.ts index fdd97bff..25e7da6c 100644 --- a/src/security/threats/threat-detection.service.ts +++ b/src/security/threats/threat-detection.service.ts @@ -25,12 +25,7 @@ export class ThreatDetectionService { private readonly failedAttempts: LRUCache; private lastEvictionWarnAt = 0; - constructor( - @Optional() options?: { - max?: number; - ttlMs?: number; - }, - ) { + constructor(@Optional() options?: { max?: number; ttlMs?: number }) { const max = options?.max ?? ThreatDetectionService.MAX_ENTRIES; const ttl = options?.ttlMs ?? ThreatDetectionService.TTL_MS; @@ -49,8 +44,8 @@ export class ThreatDetectionService { this.lastEvictionWarnAt = now; this.logger.warn( `LRU eviction triggered on failedAttempts cache (cap=${max}). ` + - `Sustained pressure indicates a potential IP-rotation attack; ` + - `consider raising MAX_ENTRIES or migrating to a distributed store.`, + 'Sustained pressure indicates a potential IP-rotation attack; ' + + 'consider raising MAX_ENTRIES or migrating to a distributed store.', ); }, }); From c8783196c8c420daeda9f30e50563f4b9c9aab1e Mon Sep 17 00:00:00 2001 From: Musa Khalid Date: Sun, 28 Jun 2026 00:10:23 +0100 Subject: [PATCH 3/3] chore: update pnpm-lock.yaml with lru-cache@^11.0.0 --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a86a7f5b..efe7238b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,6 +233,9 @@ importers: jwks-rsa: specifier: ^4.0.1 version: 4.0.1 + lru-cache: + specifier: ^11.0.0 + version: 11.5.1 multer: specifier: ^2.0.1 version: 2.1.1