secure-api-guard 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ 'use strict';
2
+
3
+ const { defaultLogger } = require('../utils/logger');
4
+
5
+ const LUA_INCR_SCRIPT = `
6
+ local key = KEYS[1]
7
+ local windowMs = tonumber(ARGV[1])
8
+ local current = redis.call('INCR', key)
9
+ if current == 1 then
10
+ redis.call('PEXPIRE', key, windowMs)
11
+ end
12
+ local ttl = redis.call('PTTL', key)
13
+ if ttl < 0 then
14
+ redis.call('PEXPIRE', key, windowMs)
15
+ ttl = windowMs
16
+ end
17
+ return { current, ttl }
18
+ `;
19
+
20
+ /**
21
+ * Distributed Redis store for rate limiting across multiple instances.
22
+ */
23
+ class RedisStore {
24
+ /**
25
+ * @param {Object} options
26
+ * @param {any} options.client - Redis client (ioredis or node-redis v4+).
27
+ * @param {string} [options.prefix='sag:rl:'] - Key prefix for rate limiting.
28
+ * @param {number} [options.windowMs=60000] - Rate limit window in milliseconds.
29
+ * @param {boolean} [options.passOnConnectionError=true] - If Redis fails, fail open to avoid taking down API.
30
+ * @param {import('../utils/logger').SecurityLogger} [options.logger] - Logger instance.
31
+ */
32
+ constructor(options = {}) {
33
+ if (!options.client) {
34
+ throw new Error('RedisStore requires a "client" instance (ioredis or redis).');
35
+ }
36
+
37
+ this.client = options.client;
38
+ this.prefix = options.prefix || 'sag:rl:';
39
+ this.windowMs = options.windowMs || 60000;
40
+ this.passOnConnectionError = options.passOnConnectionError !== false;
41
+ this.logger = options.logger || defaultLogger;
42
+ this._evalMode = null;
43
+ }
44
+
45
+ /**
46
+ * Called by express-rate-limit with middleware options.
47
+ * @param {Object} options
48
+ */
49
+ init(options) {
50
+ if (options && options.windowMs) {
51
+ this.windowMs = options.windowMs;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Prefix a rate limit key.
57
+ * @param {string} key
58
+ * @returns {string}
59
+ */
60
+ _prefixKey(key) {
61
+ return `${this.prefix}${key}`;
62
+ }
63
+
64
+ /**
65
+ * Execute Lua script across ioredis or node-redis v4+.
66
+ *
67
+ * @param {string} key
68
+ * @param {number} windowMs
69
+ * @returns {Promise<[number, number]>} [currentHits, ttlMs]
70
+ */
71
+ async _evalLua(key, windowMs) {
72
+ const fullKey = this._prefixKey(key);
73
+
74
+ if (typeof this.client.eval !== 'function') {
75
+ throw new Error('Provided Redis client does not support eval command.');
76
+ }
77
+
78
+ if (this._evalMode === 'node-redis') {
79
+ return this.client.eval(LUA_INCR_SCRIPT, { keys: [fullKey], arguments: [String(windowMs)] });
80
+ }
81
+ if (this._evalMode === 'ioredis') {
82
+ return this.client.eval(LUA_INCR_SCRIPT, 1, fullKey, String(windowMs));
83
+ }
84
+
85
+ // Auto-detect client signature on first execution
86
+ try {
87
+ const res = await this.client.eval(LUA_INCR_SCRIPT, {
88
+ keys: [fullKey],
89
+ arguments: [String(windowMs)]
90
+ });
91
+ this._evalMode = 'node-redis';
92
+ return res;
93
+ } catch {
94
+ const res = await this.client.eval(LUA_INCR_SCRIPT, 1, fullKey, String(windowMs));
95
+ this._evalMode = 'ioredis';
96
+ return res;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Increment hits for a key.
102
+ *
103
+ * @param {string} key
104
+ * @returns {Promise<{ totalHits: number, resetTime: Date }>}
105
+ */
106
+ async increment(key) {
107
+ try {
108
+ const result = await this._evalLua(key, this.windowMs);
109
+ const totalHits = Number(result[0]);
110
+ const ttlMs = Number(result[1]);
111
+ const resetTime = new Date(Date.now() + Math.max(0, ttlMs));
112
+
113
+ return {
114
+ totalHits,
115
+ resetTime
116
+ };
117
+ } catch (err) {
118
+ this.logger.error('REDIS_STORE_ERROR', `Redis rate-limiter store failure: ${err.message}`, {
119
+ details: { key, error: err.message }
120
+ });
121
+
122
+ if (!this.passOnConnectionError) {
123
+ throw err;
124
+ }
125
+
126
+ // Fail open: return non-blocking hit count
127
+ return {
128
+ totalHits: 1,
129
+ resetTime: new Date(Date.now() + this.windowMs)
130
+ };
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Decrement hits for a key.
136
+ *
137
+ * @param {string} key
138
+ * @returns {Promise<void>}
139
+ */
140
+ async decrement(key) {
141
+ try {
142
+ const fullKey = this._prefixKey(key);
143
+ if (typeof this.client.decr === 'function') {
144
+ await this.client.decr(fullKey);
145
+ }
146
+ } catch (err) {
147
+ this.logger.warn('REDIS_STORE_ERROR', `Redis decrement failed: ${err.message}`, {
148
+ details: { key }
149
+ });
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Reset hits for a single key.
155
+ *
156
+ * @param {string} key
157
+ * @returns {Promise<void>}
158
+ */
159
+ async resetKey(key) {
160
+ try {
161
+ const fullKey = this._prefixKey(key);
162
+ if (typeof this.client.del === 'function') {
163
+ await this.client.del(fullKey);
164
+ }
165
+ } catch (err) {
166
+ this.logger.warn('REDIS_STORE_ERROR', `Redis resetKey failed: ${err.message}`, {
167
+ details: { key }
168
+ });
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Reset all keys matching prefix.
174
+ *
175
+ * @returns {Promise<void>}
176
+ */
177
+ async resetAll() {
178
+ try {
179
+ const pattern = `${this.prefix}*`;
180
+ let keys = [];
181
+
182
+ if (typeof this.client.keys === 'function') {
183
+ keys = await this.client.keys(pattern);
184
+ }
185
+
186
+ if (keys && keys.length > 0 && typeof this.client.del === 'function') {
187
+ await this.client.del(...keys);
188
+ }
189
+ } catch (err) {
190
+ this.logger.warn('REDIS_STORE_ERROR', `Redis resetAll failed: ${err.message}`);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Retrieve current hits for a key without incrementing.
196
+ *
197
+ * @param {string} key
198
+ * @returns {Promise<{ totalHits: number, resetTime: Date } | undefined>}
199
+ */
200
+ async get(key) {
201
+ try {
202
+ const fullKey = this._prefixKey(key);
203
+ const [hits, ttl] = await Promise.all([
204
+ this.client.get ? this.client.get(fullKey) : null,
205
+ this.client.pttl ? this.client.pttl(fullKey) : null
206
+ ]);
207
+
208
+ if (!hits) {
209
+ return undefined;
210
+ }
211
+
212
+ const totalHits = parseInt(hits, 10);
213
+ const ttlMs = parseInt(ttl, 10) || this.windowMs;
214
+
215
+ return {
216
+ totalHits,
217
+ resetTime: new Date(Date.now() + Math.max(0, ttlMs))
218
+ };
219
+ } catch (err) {
220
+ this.logger.warn('REDIS_STORE_ERROR', `Redis get failed: ${err.message}`);
221
+ return undefined;
222
+ }
223
+ }
224
+ }
225
+
226
+ module.exports = {
227
+ RedisStore
228
+ };
229
+
@@ -0,0 +1,314 @@
1
+ import { Request, Response, NextFunction, RequestHandler } from 'express';
2
+
3
+ declare global {
4
+ namespace Express {
5
+ interface Request {
6
+ apiKey?: {
7
+ id: string;
8
+ name?: string;
9
+ permissions: string[];
10
+ metadata: Record<string, any>;
11
+ };
12
+ clientIp?: string;
13
+ validated?: {
14
+ body?: any;
15
+ query?: any;
16
+ params?: any;
17
+ headers?: any;
18
+ };
19
+ detectedThreats?: Array<{
20
+ ruleId: string;
21
+ ruleName: string;
22
+ path: string;
23
+ snippet: string;
24
+ severity: string;
25
+ }>;
26
+ id?: string;
27
+ }
28
+ }
29
+ }
30
+
31
+ export interface SecurityErrorPayload {
32
+ success: false;
33
+ error: {
34
+ code: string;
35
+ message: string;
36
+ details?: any;
37
+ };
38
+ }
39
+
40
+ export class SecurityError extends Error {
41
+ statusCode: number;
42
+ code: string;
43
+ details: any;
44
+ constructor(message: string, statusCode?: number, code?: string, details?: any);
45
+ toJSON(): SecurityErrorPayload;
46
+ }
47
+
48
+ export class RateLimitError extends SecurityError {
49
+ constructor(message?: string, details?: any);
50
+ }
51
+
52
+ export class AuthenticationError extends SecurityError {
53
+ constructor(message?: string, details?: any);
54
+ }
55
+
56
+ export class ForbiddenError extends SecurityError {
57
+ constructor(message?: string, details?: any);
58
+ }
59
+
60
+ export class PayloadTooLargeError extends SecurityError {
61
+ constructor(message?: string, details?: any);
62
+ }
63
+
64
+ export class ValidationError extends SecurityError {
65
+ constructor(message?: string, details?: any);
66
+ }
67
+
68
+ export class ThreatDetectedError extends SecurityError {
69
+ constructor(message?: string, statusCode?: number, details?: any);
70
+ }
71
+
72
+ export class ConfigurationError extends SecurityError {
73
+ constructor(message: string, details?: any);
74
+ }
75
+
76
+ // Stores
77
+ export interface RateLimitStoreRecord {
78
+ totalHits: number;
79
+ resetTime: Date;
80
+ }
81
+
82
+ export class MemoryStore {
83
+ constructor(options?: { windowMs?: number; cleanupIntervalMs?: number });
84
+ init(options: { windowMs?: number }): void;
85
+ increment(key: string): Promise<RateLimitStoreRecord>;
86
+ decrement(key: string): Promise<void>;
87
+ resetKey(key: string): Promise<void>;
88
+ resetAll(): Promise<void>;
89
+ get(key: string): Promise<RateLimitStoreRecord | undefined>;
90
+ shutdown(): void;
91
+ }
92
+
93
+ export class RedisStore {
94
+ constructor(options: {
95
+ client: any;
96
+ prefix?: string;
97
+ windowMs?: number;
98
+ passOnConnectionError?: boolean;
99
+ logger?: SecurityLogger;
100
+ });
101
+ init(options: { windowMs?: number }): void;
102
+ increment(key: string): Promise<RateLimitStoreRecord>;
103
+ decrement(key: string): Promise<void>;
104
+ resetKey(key: string): Promise<void>;
105
+ resetAll(): Promise<void>;
106
+ get(key: string): Promise<RateLimitStoreRecord | undefined>;
107
+ }
108
+
109
+ // Logger
110
+ export interface SecurityLogEvent {
111
+ timestamp: string;
112
+ level: string;
113
+ eventType: string;
114
+ message: string;
115
+ requestId: string;
116
+ ip: string;
117
+ method?: string;
118
+ path?: string;
119
+ details?: Record<string, any>;
120
+ }
121
+
122
+ export class SecurityLogger {
123
+ constructor(options?: {
124
+ enabled?: boolean;
125
+ level?: 'debug' | 'info' | 'warn' | 'error' | 'silent';
126
+ customLogger?: (event: SecurityLogEvent) => void;
127
+ redactFields?: string[];
128
+ });
129
+ log(level: 'debug' | 'info' | 'warn' | 'error', eventType: string, message: string, context?: any): void;
130
+ debug(eventType: string, message: string, context?: any): void;
131
+ info(eventType: string, message: string, context?: any): void;
132
+ warn(eventType: string, message: string, context?: any): void;
133
+ error(eventType: string, message: string, context?: any): void;
134
+ }
135
+
136
+ export const defaultLogger: SecurityLogger;
137
+ export function redact(value: any, customSensitiveKeys?: string[]): any;
138
+ export function getRequestId(req: Request): string;
139
+ export const DEFAULT_SENSITIVE_KEYS: string[];
140
+
141
+ // Rate Limiter
142
+ export interface RateLimiterOptions {
143
+ windowMs?: number;
144
+ maxRequests?: number;
145
+ max?: number;
146
+ statusCode?: number;
147
+ message?: string;
148
+ keyGenerator?: (req: Request) => string;
149
+ skip?: (req: Request) => boolean;
150
+ store?: 'memory' | 'redis' | any;
151
+ redisClient?: any;
152
+ redisPrefix?: string;
153
+ passOnRedisError?: boolean;
154
+ handler?: (req: Request, res: Response, error: RateLimitError) => void;
155
+ logger?: SecurityLogger;
156
+ standardHeaders?: boolean | 'draft-6' | 'draft-7';
157
+ legacyHeaders?: boolean;
158
+ }
159
+
160
+ export function rateLimiter(options?: RateLimiterOptions): RequestHandler;
161
+ export function createRateLimiter(options?: RateLimiterOptions): RequestHandler;
162
+ export function defaultKeyGenerator(req: Request): string;
163
+
164
+ // API Key Auth
165
+ export interface ApiKeyRecord {
166
+ id?: string;
167
+ name?: string;
168
+ key?: string;
169
+ hash?: string;
170
+ expiresAt?: string | Date | number | null;
171
+ permissions?: string[];
172
+ metadata?: Record<string, any>;
173
+ }
174
+
175
+ export interface ApiKeyAuthOptions {
176
+ headerName?: string;
177
+ queryParam?: string | null;
178
+ allowBearer?: boolean;
179
+ hashAlgorithm?: string;
180
+ keys?: Array<string | ApiKeyRecord>;
181
+ getKey?: (hash: string, req: Request) => Promise<ApiKeyRecord | null | undefined>;
182
+ verifyKey?: (rawKey: string, req: Request) => Promise<boolean | ApiKeyRecord>;
183
+ requiredPermissions?: string[];
184
+ statusCode?: number;
185
+ forbiddenStatusCode?: number;
186
+ message?: string;
187
+ forbiddenMessage?: string;
188
+ handler?: (req: Request, res: Response, error: SecurityError) => void;
189
+ logger?: SecurityLogger;
190
+ }
191
+
192
+ export function apiKeyAuth(options?: ApiKeyAuthOptions): RequestHandler;
193
+ export function createApiKeyAuth(options?: ApiKeyAuthOptions): RequestHandler;
194
+ export function extractApiKey(req: Request, options: ApiKeyAuthOptions): string | null;
195
+ export function generateApiKey(options?: { prefix?: string; bytes?: number; algorithm?: string }): { key: string; hash: string; prefix: string };
196
+ export function hashApiKey(rawKey: string, algorithm?: string): string;
197
+ export function secureCompare(a: string, b: string): boolean;
198
+ export function isKeyExpired(keyRecord: any): boolean;
199
+
200
+ // IP Filter
201
+ export interface IpFilterOptions {
202
+ mode?: 'allowlist' | 'blocklist';
203
+ allowlist?: string[];
204
+ blocklist?: string[];
205
+ trustProxy?: boolean | string[] | number;
206
+ getIp?: (req: Request) => string;
207
+ statusCode?: number;
208
+ message?: string;
209
+ handler?: (req: Request, res: Response, error: ForbiddenError) => void;
210
+ logger?: SecurityLogger;
211
+ }
212
+
213
+ export function ipFilter(options?: IpFilterOptions): RequestHandler;
214
+ export function createIpFilter(options?: IpFilterOptions): RequestHandler;
215
+ export function parseIp(ipString: string): any;
216
+ export function formatIp(ipString: string): string;
217
+ export function parseRule(ruleString: string): any;
218
+ export function matchesRule(clientIp: any, rule: any): boolean;
219
+ export function isIpInRules(clientIpStr: string, compiledRules: any[]): boolean;
220
+ export function compileRules(ruleStrings: string[]): any[];
221
+ export function getClientIp(req: Request, options?: any): string;
222
+
223
+ // Request Size
224
+ export interface RequestSizeOptions {
225
+ maxSize?: string | number;
226
+ statusCode?: number;
227
+ message?: string;
228
+ handler?: (req: Request, res: Response, error: PayloadTooLargeError) => void;
229
+ logger?: SecurityLogger;
230
+ }
231
+
232
+ export function requestSize(options?: RequestSizeOptions): RequestHandler;
233
+ export function createRequestSize(options?: RequestSizeOptions): RequestHandler;
234
+ export function parseContentLength(req: Request): { length: number | null; error: string | null };
235
+
236
+ // Security Headers
237
+ export interface SecurityHeadersOptions {
238
+ contentSecurityPolicy?: boolean | any;
239
+ hsts?: boolean | any;
240
+ frameguard?: boolean | any;
241
+ noSniff?: boolean;
242
+ referrerPolicy?: boolean | any;
243
+ noIndex?: boolean;
244
+ permissionsPolicy?: boolean | string;
245
+ [key: string]: any;
246
+ }
247
+
248
+ export function securityHeaders(options?: SecurityHeadersOptions): RequestHandler;
249
+ export function createSecurityHeaders(options?: SecurityHeadersOptions): RequestHandler;
250
+
251
+ // Request Validator
252
+ export interface ValidationSchemas {
253
+ body?: any;
254
+ query?: any;
255
+ params?: any;
256
+ headers?: any;
257
+ }
258
+
259
+ export interface RequestValidatorOptions {
260
+ assignToReq?: boolean;
261
+ statusCode?: number;
262
+ message?: string;
263
+ handler?: (req: Request, res: Response, error: ValidationError) => void;
264
+ logger?: SecurityLogger;
265
+ }
266
+
267
+ export function requestValidator(schemas: ValidationSchemas | any, options?: RequestValidatorOptions): RequestHandler;
268
+ export function createRequestValidator(schemas: ValidationSchemas | any, options?: RequestValidatorOptions): RequestHandler;
269
+
270
+ // Threat Detector
271
+ export interface ThreatRule {
272
+ id: string;
273
+ name: string;
274
+ pattern: RegExp | ((val: any, path: string) => boolean);
275
+ severity: 'high' | 'medium' | 'low';
276
+ }
277
+
278
+ export interface ThreatMatch {
279
+ ruleId: string;
280
+ ruleName: string;
281
+ path: string;
282
+ snippet: string;
283
+ severity: string;
284
+ }
285
+
286
+ export interface ThreatDetectorOptions {
287
+ action?: 'reject' | 'log' | 'pass';
288
+ statusCode?: number;
289
+ message?: string;
290
+ inspectUrl?: boolean;
291
+ inspectQuery?: boolean;
292
+ inspectBody?: boolean;
293
+ inspectHeaders?: boolean;
294
+ whitelistedPaths?: Array<string | RegExp>;
295
+ whitelistedKeys?: string[];
296
+ customRules?: ThreatRule[];
297
+ handler?: (req: Request, res: Response, error: ThreatDetectedError) => void;
298
+ logger?: SecurityLogger;
299
+ }
300
+
301
+ export function threatDetector(options?: ThreatDetectorOptions): RequestHandler;
302
+ export function createThreatDetector(options?: ThreatDetectorOptions): RequestHandler;
303
+ export const DEFAULT_THREAT_PATTERNS: ThreatRule[];
304
+ export function scanValue(value: any, rules: ThreatRule[], currentPath?: string, whitelistedKeys?: string[]): ThreatMatch[];
305
+
306
+ // Helper
307
+ export function sendSecurityResponse(
308
+ req: Request,
309
+ res: Response,
310
+ error: SecurityError | Error | string,
311
+ statusCode?: number,
312
+ customHandler?: (req: Request, res: Response, error: any) => void
313
+ ): void;
314
+
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Base security error class.
5
+ */
6
+ class SecurityError extends Error {
7
+ /**
8
+ * @param {string} message - Human-readable error message.
9
+ * @param {number} [statusCode=500] - HTTP status code.
10
+ * @param {string} [code='SECURITY_ERROR'] - Machine-readable error code.
11
+ * @param {Record<string, any>} [details=null] - Optional structured error details.
12
+ */
13
+ constructor(message, statusCode = 500, code = 'SECURITY_ERROR', details = null) {
14
+ super(message);
15
+ this.name = this.constructor.name;
16
+ this.statusCode = statusCode;
17
+ this.code = code;
18
+ this.details = details;
19
+ if (Error.captureStackTrace) {
20
+ Error.captureStackTrace(this, this.constructor);
21
+ }
22
+ }
23
+
24
+ toJSON() {
25
+ return {
26
+ success: false,
27
+ error: {
28
+ code: this.code,
29
+ message: this.message,
30
+ ...(this.details ? { details: this.details } : {})
31
+ }
32
+ };
33
+ }
34
+ }
35
+
36
+ class RateLimitError extends SecurityError {
37
+ constructor(message = 'Too many requests, please try again later.', details = null) {
38
+ super(message, 429, 'RATE_LIMIT_EXCEEDED', details);
39
+ }
40
+ }
41
+
42
+ class AuthenticationError extends SecurityError {
43
+ constructor(message = 'Unauthorized: Invalid or missing API key.', details = null) {
44
+ super(message, 401, 'UNAUTHORIZED', details);
45
+ }
46
+ }
47
+
48
+ class ForbiddenError extends SecurityError {
49
+ constructor(message = 'Forbidden: Access denied.', details = null) {
50
+ super(message, 403, 'FORBIDDEN', details);
51
+ }
52
+ }
53
+
54
+ class PayloadTooLargeError extends SecurityError {
55
+ constructor(message = 'Payload Too Large: Request body exceeds maximum allowed size.', details = null) {
56
+ super(message, 413, 'PAYLOAD_TOO_LARGE', details);
57
+ }
58
+ }
59
+
60
+ class ValidationError extends SecurityError {
61
+ constructor(message = 'Validation failed: Invalid request parameters.', details = null) {
62
+ super(message, 400, 'VALIDATION_ERROR', details);
63
+ }
64
+ }
65
+
66
+ class ThreatDetectedError extends SecurityError {
67
+ constructor(message = 'Forbidden: Suspicious request pattern detected.', statusCode = 403, details = null) {
68
+ super(message, statusCode, 'THREAT_DETECTED', details);
69
+ }
70
+ }
71
+
72
+ class ConfigurationError extends SecurityError {
73
+ constructor(message, details = null) {
74
+ super(message, 500, 'CONFIGURATION_ERROR', details);
75
+ }
76
+ }
77
+
78
+ module.exports = {
79
+ SecurityError,
80
+ RateLimitError,
81
+ AuthenticationError,
82
+ ForbiddenError,
83
+ PayloadTooLargeError,
84
+ ValidationError,
85
+ ThreatDetectedError,
86
+ ConfigurationError
87
+ };
88
+