secure-api-guard 0.0.0-stage → 1.0.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,222 @@
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
+ }
43
+
44
+ /**
45
+ * Called by express-rate-limit with middleware options.
46
+ * @param {Object} options
47
+ */
48
+ init(options) {
49
+ if (options && options.windowMs) {
50
+ this.windowMs = options.windowMs;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Prefix a rate limit key.
56
+ * @param {string} key
57
+ * @returns {string}
58
+ */
59
+ _prefixKey(key) {
60
+ return `${this.prefix}${key}`;
61
+ }
62
+
63
+ /**
64
+ * Execute Lua script across ioredis or node-redis v4+.
65
+ *
66
+ * @param {string} key
67
+ * @param {number} windowMs
68
+ * @returns {Promise<[number, number]>} [currentHits, ttlMs]
69
+ */
70
+ async _evalLua(key, windowMs) {
71
+ const fullKey = this._prefixKey(key);
72
+
73
+ // Node-redis v4+ client.eval syntax
74
+ if (typeof this.client.eval === 'function') {
75
+ try {
76
+ // Attempt node-redis v4 object parameters
77
+ const res = await this.client.eval(LUA_INCR_SCRIPT, {
78
+ keys: [fullKey],
79
+ arguments: [String(windowMs)]
80
+ });
81
+ return res;
82
+ } catch (err) {
83
+ // If node-redis object parameter syntax failed, try ioredis positional syntax:
84
+ // eval(script, numkeys, key1, ..., arg1, ...)
85
+ const res = await this.client.eval(LUA_INCR_SCRIPT, 1, fullKey, String(windowMs));
86
+ return res;
87
+ }
88
+ }
89
+
90
+ throw new Error('Provided Redis client does not support eval command.');
91
+ }
92
+
93
+ /**
94
+ * Increment hits for a key.
95
+ *
96
+ * @param {string} key
97
+ * @returns {Promise<{ totalHits: number, resetTime: Date }>}
98
+ */
99
+ async increment(key) {
100
+ try {
101
+ const result = await this._evalLua(key, this.windowMs);
102
+ const totalHits = Number(result[0]);
103
+ const ttlMs = Number(result[1]);
104
+ const resetTime = new Date(Date.now() + Math.max(0, ttlMs));
105
+
106
+ return {
107
+ totalHits,
108
+ resetTime
109
+ };
110
+ } catch (err) {
111
+ this.logger.error('REDIS_STORE_ERROR', `Redis rate-limiter store failure: ${err.message}`, {
112
+ details: { key, error: err.message }
113
+ });
114
+
115
+ if (!this.passOnConnectionError) {
116
+ throw err;
117
+ }
118
+
119
+ // Fail open: return non-blocking hit count
120
+ return {
121
+ totalHits: 1,
122
+ resetTime: new Date(Date.now() + this.windowMs)
123
+ };
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Decrement hits for a key.
129
+ *
130
+ * @param {string} key
131
+ * @returns {Promise<void>}
132
+ */
133
+ async decrement(key) {
134
+ try {
135
+ const fullKey = this._prefixKey(key);
136
+ if (typeof this.client.decr === 'function') {
137
+ await this.client.decr(fullKey);
138
+ }
139
+ } catch (err) {
140
+ this.logger.warn('REDIS_STORE_ERROR', `Redis decrement failed: ${err.message}`, {
141
+ details: { key }
142
+ });
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Reset hits for a single key.
148
+ *
149
+ * @param {string} key
150
+ * @returns {Promise<void>}
151
+ */
152
+ async resetKey(key) {
153
+ try {
154
+ const fullKey = this._prefixKey(key);
155
+ if (typeof this.client.del === 'function') {
156
+ await this.client.del(fullKey);
157
+ }
158
+ } catch (err) {
159
+ this.logger.warn('REDIS_STORE_ERROR', `Redis resetKey failed: ${err.message}`, {
160
+ details: { key }
161
+ });
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Reset all keys matching prefix.
167
+ *
168
+ * @returns {Promise<void>}
169
+ */
170
+ async resetAll() {
171
+ try {
172
+ const pattern = `${this.prefix}*`;
173
+ let keys = [];
174
+
175
+ if (typeof this.client.keys === 'function') {
176
+ keys = await this.client.keys(pattern);
177
+ }
178
+
179
+ if (keys && keys.length > 0 && typeof this.client.del === 'function') {
180
+ await this.client.del(...keys);
181
+ }
182
+ } catch (err) {
183
+ this.logger.warn('REDIS_STORE_ERROR', `Redis resetAll failed: ${err.message}`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Retrieve current hits for a key without incrementing.
189
+ *
190
+ * @param {string} key
191
+ * @returns {Promise<{ totalHits: number, resetTime: Date } | undefined>}
192
+ */
193
+ async get(key) {
194
+ try {
195
+ const fullKey = this._prefixKey(key);
196
+ const [hits, ttl] = await Promise.all([
197
+ this.client.get ? this.client.get(fullKey) : null,
198
+ this.client.pttl ? this.client.pttl(fullKey) : null
199
+ ]);
200
+
201
+ if (!hits) {
202
+ return undefined;
203
+ }
204
+
205
+ const totalHits = parseInt(hits, 10);
206
+ const ttlMs = parseInt(ttl, 10) || this.windowMs;
207
+
208
+ return {
209
+ totalHits,
210
+ resetTime: new Date(Date.now() + Math.max(0, ttlMs))
211
+ };
212
+ } catch (err) {
213
+ this.logger.warn('REDIS_STORE_ERROR', `Redis get failed: ${err.message}`);
214
+ return undefined;
215
+ }
216
+ }
217
+ }
218
+
219
+ module.exports = {
220
+ RedisStore
221
+ };
222
+
@@ -0,0 +1,313 @@
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 parseRule(ruleString: string): any;
217
+ export function matchesRule(clientIp: any, rule: any): boolean;
218
+ export function isIpInRules(clientIpStr: string, compiledRules: any[]): boolean;
219
+ export function compileRules(ruleStrings: string[]): any[];
220
+ export function getClientIp(req: Request, options?: any): string;
221
+
222
+ // Request Size
223
+ export interface RequestSizeOptions {
224
+ maxSize?: string | number;
225
+ statusCode?: number;
226
+ message?: string;
227
+ handler?: (req: Request, res: Response, error: PayloadTooLargeError) => void;
228
+ logger?: SecurityLogger;
229
+ }
230
+
231
+ export function requestSize(options?: RequestSizeOptions): RequestHandler;
232
+ export function createRequestSize(options?: RequestSizeOptions): RequestHandler;
233
+ export function parseContentLength(req: Request): { length: number | null; error: string | null };
234
+
235
+ // Security Headers
236
+ export interface SecurityHeadersOptions {
237
+ contentSecurityPolicy?: boolean | any;
238
+ hsts?: boolean | any;
239
+ frameguard?: boolean | any;
240
+ noSniff?: boolean;
241
+ referrerPolicy?: boolean | any;
242
+ noIndex?: boolean;
243
+ permissionsPolicy?: boolean | string;
244
+ [key: string]: any;
245
+ }
246
+
247
+ export function securityHeaders(options?: SecurityHeadersOptions): RequestHandler;
248
+ export function createSecurityHeaders(options?: SecurityHeadersOptions): RequestHandler;
249
+
250
+ // Request Validator
251
+ export interface ValidationSchemas {
252
+ body?: any;
253
+ query?: any;
254
+ params?: any;
255
+ headers?: any;
256
+ }
257
+
258
+ export interface RequestValidatorOptions {
259
+ assignToReq?: boolean;
260
+ statusCode?: number;
261
+ message?: string;
262
+ handler?: (req: Request, res: Response, error: ValidationError) => void;
263
+ logger?: SecurityLogger;
264
+ }
265
+
266
+ export function requestValidator(schemas: ValidationSchemas | any, options?: RequestValidatorOptions): RequestHandler;
267
+ export function createRequestValidator(schemas: ValidationSchemas | any, options?: RequestValidatorOptions): RequestHandler;
268
+
269
+ // Threat Detector
270
+ export interface ThreatRule {
271
+ id: string;
272
+ name: string;
273
+ pattern: RegExp | ((val: any, path: string) => boolean);
274
+ severity: 'high' | 'medium' | 'low';
275
+ }
276
+
277
+ export interface ThreatMatch {
278
+ ruleId: string;
279
+ ruleName: string;
280
+ path: string;
281
+ snippet: string;
282
+ severity: string;
283
+ }
284
+
285
+ export interface ThreatDetectorOptions {
286
+ action?: 'reject' | 'log' | 'pass';
287
+ statusCode?: number;
288
+ message?: string;
289
+ inspectUrl?: boolean;
290
+ inspectQuery?: boolean;
291
+ inspectBody?: boolean;
292
+ inspectHeaders?: boolean;
293
+ whitelistedPaths?: Array<string | RegExp>;
294
+ whitelistedKeys?: string[];
295
+ customRules?: ThreatRule[];
296
+ handler?: (req: Request, res: Response, error: ThreatDetectedError) => void;
297
+ logger?: SecurityLogger;
298
+ }
299
+
300
+ export function threatDetector(options?: ThreatDetectorOptions): RequestHandler;
301
+ export function createThreatDetector(options?: ThreatDetectorOptions): RequestHandler;
302
+ export const DEFAULT_THREAT_PATTERNS: ThreatRule[];
303
+ export function scanValue(value: any, rules: ThreatRule[], currentPath?: string, whitelistedKeys?: string[]): ThreatMatch[];
304
+
305
+ // Helper
306
+ export function sendSecurityResponse(
307
+ req: Request,
308
+ res: Response,
309
+ error: SecurityError | Error | string,
310
+ statusCode?: number,
311
+ customHandler?: (req: Request, res: Response, error: any) => void
312
+ ): void;
313
+
@@ -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
+