@roboteby/parry 1.1.0-rc.1
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.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/config/defaults.js +65 -0
- package/constants/patterns.js +77 -0
- package/package.json +89 -0
- package/src/admin/admin-router.js +106 -0
- package/src/admin/auth/admin-auth.js +176 -0
- package/src/admin/auth/index.js +13 -0
- package/src/admin/auth/strategies/alb-auth.js +49 -0
- package/src/admin/auth/strategies/cloudflare-access.js +34 -0
- package/src/admin/auth/strategies/combined.js +50 -0
- package/src/admin/auth/strategies/ip-allowlist.js +13 -0
- package/src/admin/auth/strategies/none.js +20 -0
- package/src/admin/auth/strategies/token.js +25 -0
- package/src/admin/auth/strategies/trusted-proxy.js +52 -0
- package/src/admin/auth/utils/constant-time.js +18 -0
- package/src/admin/auth/utils/external-identity.js +156 -0
- package/src/admin/auth/utils/header-utils.js +39 -0
- package/src/admin/auth/utils/result.js +39 -0
- package/src/admin/ban-normalizer.js +98 -0
- package/src/admin/index.js +12 -0
- package/src/admin/response.js +41 -0
- package/src/brute-force/brute-force-guard.js +268 -0
- package/src/brute-force/index.js +32 -0
- package/src/brute-force/key-builder.js +164 -0
- package/src/brute-force/result.js +35 -0
- package/src/core/engine.js +264 -0
- package/src/core/index.js +7 -0
- package/src/core/logger.js +3 -0
- package/src/core/rate-limit-result.js +13 -0
- package/src/core/rateLimiter.js +3 -0
- package/src/core/scoring.js +18 -0
- package/src/core/threat-event.js +69 -0
- package/src/detectors/hpp.js +30 -0
- package/src/detectors/index.js +19 -0
- package/src/detectors/nosql.js +53 -0
- package/src/detectors/path-traversal.js +72 -0
- package/src/detectors/prototype-pollution.js +69 -0
- package/src/detectors/request-shape.js +76 -0
- package/src/detectors/sql.js +18 -0
- package/src/detectors/xss.js +18 -0
- package/src/events/event-bus.js +51 -0
- package/src/events/index.js +19 -0
- package/src/events/memory-event-store.js +64 -0
- package/src/events/sanitize-event.js +54 -0
- package/src/events/threat-event.js +174 -0
- package/src/express/ip-resolver.js +109 -0
- package/src/express/middleware.js +379 -0
- package/src/express/request-targets.js +35 -0
- package/src/express/response.js +14 -0
- package/src/index.js +41 -0
- package/src/logger/console-reporter.js +75 -0
- package/src/middleware/index.js +7 -0
- package/src/middleware/parry_ddos.js +3 -0
- package/src/observability/index.js +6 -0
- package/src/observability/metrics.js +61 -0
- package/src/observability/snapshot.js +48 -0
- package/src/policies/index.js +15 -0
- package/src/policies/matcher.js +48 -0
- package/src/policies/normalize-policy.js +94 -0
- package/src/policies/presets.js +34 -0
- package/src/rate-limit/keys.js +7 -0
- package/src/rate-limit/limiter.js +124 -0
- package/src/stores/README.md +51 -0
- package/src/stores/index.js +6 -0
- package/src/stores/memory-store.js +278 -0
- package/src/stores/redis-store.js +349 -0
- package/src/utils/decode.js +56 -0
- package/src/utils/flatten.js +27 -0
- package/src/utils/normalize.js +21 -0
- package/types/index.d.ts +555 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { DEFAULTS } = require('../../config/defaults');
|
|
4
|
+
const { analyzeRequest } = require('../core/engine');
|
|
5
|
+
const { RateLimiter } = require('../rate-limit/limiter');
|
|
6
|
+
const { ThreatLogger } = require('../logger/console-reporter');
|
|
7
|
+
const { EventBus, MemoryEventStore } = require('../events');
|
|
8
|
+
const { Metrics } = require('../observability');
|
|
9
|
+
const { buildPolicies, findMatchingPolicy } = require('../policies');
|
|
10
|
+
const {
|
|
11
|
+
attachParryRequestApi,
|
|
12
|
+
buildRouteRateLimitKey,
|
|
13
|
+
checkBruteForceBlock,
|
|
14
|
+
createBlockedResponse,
|
|
15
|
+
createBruteForceContext,
|
|
16
|
+
observeAuthenticationResult,
|
|
17
|
+
} = require('../brute-force');
|
|
18
|
+
const { resolveClientIP } = require('./ip-resolver');
|
|
19
|
+
const { collectRequestTargets } = require('./request-targets');
|
|
20
|
+
const { setRateLimitHeaders, respond } = require('./response');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Detects SQL Injection, XSS and NoSQL Injection in real-time.
|
|
24
|
+
* Applies intelligent Rate Limiting with automatic banning for suspicious behavior.
|
|
25
|
+
*
|
|
26
|
+
* @param {import('../../types/index').Parry_DDoSOptions} options
|
|
27
|
+
* @returns {import('express').RequestHandler}
|
|
28
|
+
*/
|
|
29
|
+
function Parry_DDoS(options = {}) {
|
|
30
|
+
return createParry(options).middleware();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createParry(options = {}) {
|
|
34
|
+
const config = mergeConfig(options);
|
|
35
|
+
const rateLimiter = new RateLimiter(config, config.store);
|
|
36
|
+
const eventStore = new MemoryEventStore({ maxEvents: config.events.maxEvents });
|
|
37
|
+
const eventBus = new EventBus({ eventStore });
|
|
38
|
+
const metrics = new Metrics();
|
|
39
|
+
const consoleReporter = new ThreatLogger(config.logThreats);
|
|
40
|
+
|
|
41
|
+
eventBus.onThreat((event) => consoleReporter.log(event));
|
|
42
|
+
eventBus.onThreat((event) => metrics.recordEvent(event));
|
|
43
|
+
if (typeof config.onThreat === 'function') eventBus.onThreat(config.onThreat);
|
|
44
|
+
if (typeof config.onEvent === 'function') eventBus.onThreat(config.onEvent);
|
|
45
|
+
if (typeof config.onStoreError === 'function') {
|
|
46
|
+
eventBus.onThreat((event) => {
|
|
47
|
+
if (event.type !== 'STORE_ERROR') return;
|
|
48
|
+
config.onStoreError(new Error(event.reason || 'Store error'), event);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const eventReporter = createEventReporter(eventBus);
|
|
53
|
+
const context = {
|
|
54
|
+
config,
|
|
55
|
+
rateLimiter,
|
|
56
|
+
logger: eventReporter,
|
|
57
|
+
store: rateLimiter.store,
|
|
58
|
+
eventBus,
|
|
59
|
+
eventStore,
|
|
60
|
+
metrics,
|
|
61
|
+
policies: config.policies,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const middleware = function Parry_DDoSMiddleware(req, res, next) {
|
|
65
|
+
return handleRequest(req, res, next, context).catch(next);
|
|
66
|
+
};
|
|
67
|
+
Object.defineProperty(middleware, '__parryContext', {
|
|
68
|
+
value: context,
|
|
69
|
+
enumerable: false,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
middleware() {
|
|
74
|
+
return middleware;
|
|
75
|
+
},
|
|
76
|
+
eventBus,
|
|
77
|
+
metrics,
|
|
78
|
+
eventStore,
|
|
79
|
+
store: rateLimiter.store,
|
|
80
|
+
policies: config.policies,
|
|
81
|
+
getContext() {
|
|
82
|
+
return context;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function handleRequest(req, res, next, context) {
|
|
88
|
+
const { config, rateLimiter, logger, store, eventBus, metrics } = context;
|
|
89
|
+
metrics.recordRequest('started');
|
|
90
|
+
const ip = resolveClientIP(req, config);
|
|
91
|
+
const timestamp = new Date().toISOString();
|
|
92
|
+
const url = req.originalUrl || req.url;
|
|
93
|
+
const requestId = resolveRequestId(req, res, config.requestId);
|
|
94
|
+
const requestData = {
|
|
95
|
+
ip,
|
|
96
|
+
timestamp,
|
|
97
|
+
method: req.method,
|
|
98
|
+
url,
|
|
99
|
+
path: stripQuery(url || '/'),
|
|
100
|
+
headers: req.headers || {},
|
|
101
|
+
query: req.query || {},
|
|
102
|
+
params: req.params || {},
|
|
103
|
+
body: req.body,
|
|
104
|
+
targets: collectRequestTargets(req, config.maxObjectDepth),
|
|
105
|
+
requestId,
|
|
106
|
+
userAgent: getHeader(req.headers || {}, 'user-agent'),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const policy = findMatchingPolicy(config.policies, requestData);
|
|
110
|
+
const bruteForceContext = createBruteForceContext({
|
|
111
|
+
policy,
|
|
112
|
+
requestData,
|
|
113
|
+
req,
|
|
114
|
+
res,
|
|
115
|
+
store,
|
|
116
|
+
config,
|
|
117
|
+
logger,
|
|
118
|
+
eventBus,
|
|
119
|
+
});
|
|
120
|
+
attachParryRequestApi(req, bruteForceContext);
|
|
121
|
+
req.parry.requestId = requestId;
|
|
122
|
+
|
|
123
|
+
const bruteForce = await checkBruteForceBlock(bruteForceContext);
|
|
124
|
+
if (bruteForce.blocked) {
|
|
125
|
+
if (bruteForce.storeFailure) {
|
|
126
|
+
return respond(res, bruteForce.statusCode, 'Rate limit store unavailable.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const response = createBlockedResponse(bruteForce);
|
|
130
|
+
setHeaders(res, response.headers);
|
|
131
|
+
metrics.recordRequest('blocked');
|
|
132
|
+
return res.status(response.statusCode).json(response.body);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const routeRateLimit = await checkRouteRateLimit({
|
|
136
|
+
policy,
|
|
137
|
+
requestData,
|
|
138
|
+
store,
|
|
139
|
+
config,
|
|
140
|
+
logger,
|
|
141
|
+
eventBus,
|
|
142
|
+
req,
|
|
143
|
+
res,
|
|
144
|
+
});
|
|
145
|
+
if (routeRateLimit?.blocked) {
|
|
146
|
+
if (routeRateLimit.storeFailure) {
|
|
147
|
+
return respond(res, routeRateLimit.statusCode, 'Rate limit store unavailable.');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setRateLimitHeaders(res, routeRateLimit.headerConfig, routeRateLimit.rateLimit);
|
|
151
|
+
metrics.recordRequest('blocked');
|
|
152
|
+
return respond(res, 429, 'Request limit reached. Please try again shortly.');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
observeAuthenticationResult(bruteForceContext);
|
|
156
|
+
|
|
157
|
+
const engineConfig =
|
|
158
|
+
policy && policy.inheritGlobalRateLimit === false ? { ...config, rateLimit: false } : config;
|
|
159
|
+
const decision = await analyzeRequest(requestData, { config: engineConfig, rateLimiter, logger });
|
|
160
|
+
|
|
161
|
+
if (engineConfig.rateLimit && engineConfig.rateLimitConfig.headers && decision.rateLimit) {
|
|
162
|
+
setRateLimitHeaders(res, engineConfig, decision.rateLimit);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!decision.blocked) {
|
|
166
|
+
metrics.recordRequest('allowed');
|
|
167
|
+
return next();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (decision.event)
|
|
171
|
+
eventBus.emitThreat({ ...decision.event, statusCode: decision.statusCode }, { req, res });
|
|
172
|
+
metrics.recordRequest('blocked');
|
|
173
|
+
|
|
174
|
+
return respond(res, decision.statusCode, decision.message, decision.responseExtra);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function mergeConfig(options) {
|
|
178
|
+
const config = { ...DEFAULTS, ...options };
|
|
179
|
+
|
|
180
|
+
for (const key of ['hpp', 'prototypePollution', 'pathTraversal', 'requestShape']) {
|
|
181
|
+
config[key] = {
|
|
182
|
+
...DEFAULTS[key],
|
|
183
|
+
...(options[key] || {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const key of ['events', 'admin', 'requestId']) {
|
|
188
|
+
config[key] = {
|
|
189
|
+
...DEFAULTS[key],
|
|
190
|
+
...(options[key] || {}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
config.rateLimitConfig = normalizeRateLimitConfig(config, options);
|
|
195
|
+
config.rateLimit = config.rateLimitConfig.enabled;
|
|
196
|
+
config.maxRequests = config.rateLimitConfig.maxRequests;
|
|
197
|
+
config.windowMs = config.rateLimitConfig.windowMs;
|
|
198
|
+
config.storeFailureMode =
|
|
199
|
+
options.storeFailureMode === 'fail-closed' ? 'fail-closed' : 'fail-open';
|
|
200
|
+
config.policies = buildPolicies(options);
|
|
201
|
+
config.bruteForce =
|
|
202
|
+
options.bruteForce === false
|
|
203
|
+
? false
|
|
204
|
+
: {
|
|
205
|
+
...DEFAULTS.bruteForce,
|
|
206
|
+
...(options.bruteForce || {}),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
return config;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function normalizeRateLimitConfig(config, options) {
|
|
213
|
+
const rateLimitOption = options.rateLimit;
|
|
214
|
+
const nested = rateLimitOption && typeof rateLimitOption === 'object' ? rateLimitOption : {};
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
enabled: rateLimitOption !== false && nested.enabled !== false,
|
|
218
|
+
maxRequests: nested.max || nested.maxRequests || config.maxRequests,
|
|
219
|
+
windowMs: nested.windowMs || config.windowMs,
|
|
220
|
+
headers: nested.headers !== false,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function checkRouteRateLimit(context) {
|
|
225
|
+
const { policy, requestData, store, config, logger, req, res } = context;
|
|
226
|
+
if (!policy?.rateLimit?.enabled) return null;
|
|
227
|
+
|
|
228
|
+
const key = buildRouteRateLimitKey(policy, requestData);
|
|
229
|
+
if (!key) return null;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const counter = await store.incrementCounter(key.key, policy.rateLimit.windowMs, {
|
|
233
|
+
policyName: policy.name,
|
|
234
|
+
keyType: key.type,
|
|
235
|
+
reason: 'route_rate_limit',
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const remaining = Math.max(0, policy.rateLimit.max - counter.count);
|
|
239
|
+
const rateLimit = {
|
|
240
|
+
limited: counter.count > policy.rateLimit.max,
|
|
241
|
+
banned: false,
|
|
242
|
+
remaining,
|
|
243
|
+
resetAt: counter.resetAt,
|
|
244
|
+
banExpiresAt: null,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
if (!rateLimit.limited) return { blocked: false, rateLimit };
|
|
248
|
+
|
|
249
|
+
const event = createRouteRateLimitEvent({ policy, requestData, keyTypes: [key.type] });
|
|
250
|
+
emitPolicyEvent({ eventBus: context.eventBus, logger, event, req, res });
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
blocked: true,
|
|
254
|
+
rateLimit,
|
|
255
|
+
headerConfig: {
|
|
256
|
+
maxRequests: policy.rateLimit.max,
|
|
257
|
+
rateLimitConfig: { maxRequests: policy.rateLimit.max },
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const mode = config.storeFailureMode === 'fail-closed' ? 'fail-closed' : 'fail-open';
|
|
262
|
+
const event = {
|
|
263
|
+
type: 'STORE_FAILURE',
|
|
264
|
+
module: 'route-rate-limit',
|
|
265
|
+
policyName: policy.name,
|
|
266
|
+
ip: requestData.ip,
|
|
267
|
+
method: requestData.method,
|
|
268
|
+
path: requestData.path,
|
|
269
|
+
timestamp: new Date().toISOString(),
|
|
270
|
+
reason: error && error.message ? error.message : String(error),
|
|
271
|
+
mode,
|
|
272
|
+
};
|
|
273
|
+
if (logger && typeof logger.logStoreError === 'function') logger.logStoreError(error, event);
|
|
274
|
+
if (mode === 'fail-open') return null;
|
|
275
|
+
return { blocked: true, storeFailure: true, statusCode: 503, event };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function createRouteRateLimitEvent({ policy, requestData, keyTypes }) {
|
|
280
|
+
return {
|
|
281
|
+
type: 'ROUTE_RATE_LIMIT_EXCEEDED',
|
|
282
|
+
module: 'route-policy',
|
|
283
|
+
detector: 'ROUTE_RATE_LIMIT',
|
|
284
|
+
policyName: policy.name,
|
|
285
|
+
ip: requestData.ip,
|
|
286
|
+
method: requestData.method,
|
|
287
|
+
path: requestData.path,
|
|
288
|
+
keyTypes,
|
|
289
|
+
severity: 'medium',
|
|
290
|
+
reason: 'route_rate_limit_exceeded',
|
|
291
|
+
timestamp: new Date().toISOString(),
|
|
292
|
+
requestId: requestData.requestId,
|
|
293
|
+
userAgent: requestData.userAgent,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function emitPolicyEvent({ eventBus, logger, event, req, res }) {
|
|
298
|
+
if (eventBus && typeof eventBus.emitThreat === 'function') {
|
|
299
|
+
eventBus.emitThreat(event, { req, res });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (logger && typeof logger.log === 'function') logger.log(event);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function setHeaders(res, headers) {
|
|
307
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
308
|
+
res.setHeader(key, value);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function stripQuery(value) {
|
|
313
|
+
const path = String(value || '/');
|
|
314
|
+
const index = path.indexOf('?');
|
|
315
|
+
return index === -1 ? path : path.slice(0, index);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function createEventReporter(eventBus) {
|
|
319
|
+
return {
|
|
320
|
+
log(event, context = {}) {
|
|
321
|
+
return eventBus.emitThreat(event, context);
|
|
322
|
+
},
|
|
323
|
+
logStoreError(error, event = {}) {
|
|
324
|
+
return eventBus.emitThreat(
|
|
325
|
+
{
|
|
326
|
+
...event,
|
|
327
|
+
type: 'STORE_ERROR',
|
|
328
|
+
severity: event.severity || 'medium',
|
|
329
|
+
action: 'error',
|
|
330
|
+
reason: error && error.message ? error.message : String(error),
|
|
331
|
+
},
|
|
332
|
+
{}
|
|
333
|
+
);
|
|
334
|
+
},
|
|
335
|
+
logHookError(error, event = {}) {
|
|
336
|
+
return eventBus.emitThreat(
|
|
337
|
+
{
|
|
338
|
+
type: 'HOOK_ERROR',
|
|
339
|
+
module: 'hook',
|
|
340
|
+
severity: 'low',
|
|
341
|
+
action: 'error',
|
|
342
|
+
reason: error && error.message ? error.message : String(error),
|
|
343
|
+
ip: event.ip,
|
|
344
|
+
method: event.method,
|
|
345
|
+
path: event.path,
|
|
346
|
+
requestId: event.requestId,
|
|
347
|
+
metadata: { sourceEventId: event.id, sourceType: event.type },
|
|
348
|
+
},
|
|
349
|
+
{}
|
|
350
|
+
);
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function resolveRequestId(req, res, config) {
|
|
356
|
+
if (!config || config.enabled === false) return undefined;
|
|
357
|
+
|
|
358
|
+
const headerName = config.header || 'x-request-id';
|
|
359
|
+
const requestId = getHeader(req.headers || {}, headerName) || createRequestId();
|
|
360
|
+
|
|
361
|
+
if (config.responseHeader) {
|
|
362
|
+
res.setHeader(config.responseHeader, requestId);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return requestId;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function createRequestId() {
|
|
369
|
+
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function getHeader(headers, name) {
|
|
373
|
+
if (!headers || !name) return undefined;
|
|
374
|
+
const lower = String(name).toLowerCase();
|
|
375
|
+
const match = Object.keys(headers).find((key) => key.toLowerCase() === lower);
|
|
376
|
+
return match ? headers[match] : undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = { Parry_DDoS, createParry, mergeConfig };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SENSITIVE_HEADERS } = require('../../constants/patterns');
|
|
4
|
+
const { flattenObject } = require('../utils/flatten');
|
|
5
|
+
const { normalizeTarget } = require('../utils/normalize');
|
|
6
|
+
|
|
7
|
+
function collectRequestTargets(req, maxDepth) {
|
|
8
|
+
const targets = [];
|
|
9
|
+
const headers = req.headers || {};
|
|
10
|
+
|
|
11
|
+
const add = (label, value) => {
|
|
12
|
+
if (value != null) targets.push(normalizeTarget({ label, value }));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
if (req.query && typeof req.query === 'object') {
|
|
16
|
+
for (const [key, value] of Object.entries(req.query)) add(`query.${key}`, value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (req.params && typeof req.params === 'object') {
|
|
20
|
+
for (const [key, value] of Object.entries(req.params)) add(`params.${key}`, value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (req.body && typeof req.body === 'object') {
|
|
24
|
+
add('body', req.body);
|
|
25
|
+
for (const target of flattenObject(req.body, 'body', maxDepth)) add(target.label, target.value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const header of SENSITIVE_HEADERS) {
|
|
29
|
+
if (headers[header]) add(`header.${header}`, headers[header]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return targets;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { collectRequestTargets };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function setRateLimitHeaders(res, config, rateLimitResult) {
|
|
4
|
+
const limit = config.rateLimitConfig?.maxRequests || config.maxRequests;
|
|
5
|
+
res.setHeader('X-RateLimit-Limit', limit);
|
|
6
|
+
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
|
|
7
|
+
res.setHeader('X-RateLimit-Reset', rateLimitResult.resetAt);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function respond(res, status, message, extra = {}) {
|
|
11
|
+
return res.status(status).json({ error: true, message, ...extra });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { setRateLimitHeaders, respond };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Parry_DDoS, createParry } = require('./middleware');
|
|
4
|
+
const { RateLimiter, ThreatLogger } = require('./core');
|
|
5
|
+
const { MemoryStore, RedisStore } = require('./stores');
|
|
6
|
+
const { EventBus, MemoryEventStore } = require('./events');
|
|
7
|
+
const { Metrics } = require('./observability');
|
|
8
|
+
const { createParryAdminRouter } = require('./admin');
|
|
9
|
+
const Policies = require('./policies');
|
|
10
|
+
const BruteForce = require('./brute-force');
|
|
11
|
+
const {
|
|
12
|
+
SQLInjectionDetector,
|
|
13
|
+
XSSDetector,
|
|
14
|
+
NoSQLDetector,
|
|
15
|
+
HPPDetector,
|
|
16
|
+
PrototypePollutionDetector,
|
|
17
|
+
PathTraversalDetector,
|
|
18
|
+
RequestShapeGuard,
|
|
19
|
+
} = require('./detectors');
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
Parry_DDoS,
|
|
23
|
+
createParry,
|
|
24
|
+
createParryAdminRouter,
|
|
25
|
+
RateLimiter,
|
|
26
|
+
ThreatLogger,
|
|
27
|
+
MemoryStore,
|
|
28
|
+
RedisStore,
|
|
29
|
+
EventBus,
|
|
30
|
+
MemoryEventStore,
|
|
31
|
+
Metrics,
|
|
32
|
+
Policies,
|
|
33
|
+
BruteForce,
|
|
34
|
+
SQLInjectionDetector,
|
|
35
|
+
XSSDetector,
|
|
36
|
+
NoSQLDetector,
|
|
37
|
+
HPPDetector,
|
|
38
|
+
PrototypePollutionDetector,
|
|
39
|
+
PathTraversalDetector,
|
|
40
|
+
RequestShapeGuard,
|
|
41
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const COLORS = {
|
|
4
|
+
reset: '\x1b[0m',
|
|
5
|
+
red: '\x1b[31m',
|
|
6
|
+
yellow: '\x1b[33m',
|
|
7
|
+
cyan: '\x1b[36m',
|
|
8
|
+
gray: '\x1b[90m',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const TYPE_COLOR = {
|
|
12
|
+
THREAT: COLORS.red,
|
|
13
|
+
BAN: COLORS.red,
|
|
14
|
+
RATE_LIMIT: COLORS.yellow,
|
|
15
|
+
STORE_FAILURE: COLORS.yellow,
|
|
16
|
+
BRUTE_FORCE_ATTEMPT: COLORS.yellow,
|
|
17
|
+
BRUTE_FORCE_BLOCK: COLORS.red,
|
|
18
|
+
BRUTE_FORCE_RESET: COLORS.cyan,
|
|
19
|
+
ROUTE_RATE_LIMIT_EXCEEDED: COLORS.yellow,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
class ThreatLogger {
|
|
23
|
+
/** @param {boolean} enabled */
|
|
24
|
+
constructor(enabled = true) {
|
|
25
|
+
this.enabled = enabled;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {import('../../types/index').ThreatLogEntry} entry */
|
|
29
|
+
log(entry) {
|
|
30
|
+
if (!this.enabled) return;
|
|
31
|
+
|
|
32
|
+
const color = TYPE_COLOR[entry.type] || COLORS.cyan;
|
|
33
|
+
const prefix = `${color}[Parry][${entry.type}]${COLORS.reset}`;
|
|
34
|
+
const meta = `${COLORS.gray}${entry.timestamp} — IP: ${entry.ip}${COLORS.reset}`;
|
|
35
|
+
|
|
36
|
+
if (entry.type === 'THREAT') {
|
|
37
|
+
console.warn(
|
|
38
|
+
`${prefix} ${meta}\n` +
|
|
39
|
+
` ${COLORS.cyan}${entry.method} ${entry.url}${COLORS.reset}\n` +
|
|
40
|
+
entry.threats.map((t) => ` ⚠ ${t.detector} in the field "${t.field}"`).join('\n')
|
|
41
|
+
);
|
|
42
|
+
} else if (entry.type === 'BAN') {
|
|
43
|
+
console.warn(`${prefix} ${meta} — ${entry.reason}`);
|
|
44
|
+
} else {
|
|
45
|
+
console.warn(`${prefix} ${meta}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
logHookError(error, entry) {
|
|
50
|
+
if (!this.enabled) return;
|
|
51
|
+
|
|
52
|
+
const timestamp = entry?.timestamp || new Date().toISOString();
|
|
53
|
+
const ip = entry?.ip || 'unknown';
|
|
54
|
+
const message = error && error.message ? error.message : String(error);
|
|
55
|
+
const prefix = `${COLORS.yellow}[Parry][HOOK_ERROR]${COLORS.reset}`;
|
|
56
|
+
const meta = `${COLORS.gray}${timestamp} — IP: ${ip}${COLORS.reset}`;
|
|
57
|
+
|
|
58
|
+
console.warn(`${prefix} ${meta} — onThreat callback failed: ${message}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logStoreError(error, entry) {
|
|
62
|
+
if (!this.enabled) return;
|
|
63
|
+
|
|
64
|
+
const timestamp = entry?.timestamp || new Date().toISOString();
|
|
65
|
+
const ip = entry?.ip || 'unknown';
|
|
66
|
+
const mode = entry?.mode || 'fail-open';
|
|
67
|
+
const message = error && error.message ? error.message : String(error);
|
|
68
|
+
const prefix = `${COLORS.yellow}[Parry][STORE_FAILURE]${COLORS.reset}`;
|
|
69
|
+
const meta = `${COLORS.gray}${timestamp} — IP: ${ip}${COLORS.reset}`;
|
|
70
|
+
|
|
71
|
+
console.warn(`${prefix} ${meta} — ${mode}: ${message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { ThreatLogger };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Parry_DDoS, createParry } = require('./parry_ddos');
|
|
4
|
+
|
|
5
|
+
// Keep the legacy public export name for compatibility while the project
|
|
6
|
+
// prepares for a future package/API name that better reflects its scope.
|
|
7
|
+
module.exports = { Parry_DDoS, createParry };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class Metrics {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.startedAt = new Date().toISOString();
|
|
6
|
+
this.startedAtMs = Date.now();
|
|
7
|
+
this.counters = {
|
|
8
|
+
totalRequests: 0,
|
|
9
|
+
allowedRequests: 0,
|
|
10
|
+
blockedRequests: 0,
|
|
11
|
+
rateLimitedRequests: 0,
|
|
12
|
+
bruteForceBlocks: 0,
|
|
13
|
+
};
|
|
14
|
+
this.eventsByType = {};
|
|
15
|
+
this.eventsBySeverity = {};
|
|
16
|
+
this.eventsByDetector = {};
|
|
17
|
+
this.eventsByAction = {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
increment(name, value = 1) {
|
|
21
|
+
this.counters[name] = (this.counters[name] || 0) + value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
recordRequest(action) {
|
|
25
|
+
if (action === 'started') this.increment('totalRequests');
|
|
26
|
+
if (action === 'allowed') this.increment('allowedRequests');
|
|
27
|
+
if (action === 'blocked') this.increment('blockedRequests');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
recordEvent(event) {
|
|
31
|
+
incrementMap(this.eventsByType, event.type);
|
|
32
|
+
incrementMap(this.eventsBySeverity, event.severity);
|
|
33
|
+
incrementMap(this.eventsByDetector, event.detector);
|
|
34
|
+
incrementMap(this.eventsByAction, event.action);
|
|
35
|
+
|
|
36
|
+
if (event.type === 'RATE_LIMIT_EXCEEDED' || event.type === 'ROUTE_RATE_LIMIT_EXCEEDED') {
|
|
37
|
+
this.increment('rateLimitedRequests');
|
|
38
|
+
}
|
|
39
|
+
if (event.type === 'BRUTE_FORCE_BLOCKED') this.increment('bruteForceBlocks');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
snapshot(extra = {}) {
|
|
43
|
+
return {
|
|
44
|
+
startedAt: this.startedAt,
|
|
45
|
+
uptimeMs: Date.now() - this.startedAtMs,
|
|
46
|
+
...this.counters,
|
|
47
|
+
activeBans: extra.activeBans || 0,
|
|
48
|
+
eventsByType: { ...this.eventsByType },
|
|
49
|
+
eventsBySeverity: { ...this.eventsBySeverity },
|
|
50
|
+
eventsByDetector: { ...this.eventsByDetector },
|
|
51
|
+
eventsByAction: { ...this.eventsByAction },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function incrementMap(map, key) {
|
|
57
|
+
if (!key) return;
|
|
58
|
+
map[key] = (map[key] || 0) + 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { Metrics };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function createSnapshot(context) {
|
|
4
|
+
return {
|
|
5
|
+
metrics: context.metrics.snapshot({ activeBans: countActiveBans(context.store) }),
|
|
6
|
+
policies: sanitizePolicies(context.policies || []),
|
|
7
|
+
store: describeStore(context.store),
|
|
8
|
+
events: context.eventBus.getRecentEvents({ limit: 10 }),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function describeStore(store) {
|
|
13
|
+
if (!store) return 'unknown';
|
|
14
|
+
if (store.constructor && store.constructor.name)
|
|
15
|
+
return store.constructor.name.replace(/Store$/, '').toLowerCase();
|
|
16
|
+
return 'custom';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function countActiveBans(store) {
|
|
20
|
+
if (store && typeof store.listBans === 'function') {
|
|
21
|
+
const result = store.listBans();
|
|
22
|
+
return Array.isArray(result) ? result.length : 0;
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sanitizePolicies(policies) {
|
|
28
|
+
return policies.map((policy) => ({
|
|
29
|
+
name: policy.name,
|
|
30
|
+
match: policy.match,
|
|
31
|
+
inheritGlobalRateLimit: policy.inheritGlobalRateLimit,
|
|
32
|
+
rateLimit: policy.rateLimit,
|
|
33
|
+
bruteForce: policy.bruteForce
|
|
34
|
+
? {
|
|
35
|
+
enabled: policy.bruteForce.enabled,
|
|
36
|
+
maxAttempts: policy.bruteForce.maxAttempts,
|
|
37
|
+
windowMs: policy.bruteForce.windowMs,
|
|
38
|
+
blockDurationMs: policy.bruteForce.blockDurationMs,
|
|
39
|
+
keyTypes: (policy.bruteForce.keys || []).map((key) =>
|
|
40
|
+
typeof key === 'function' ? 'custom' : key
|
|
41
|
+
),
|
|
42
|
+
resetOnSuccess: policy.bruteForce.resetOnSuccess,
|
|
43
|
+
}
|
|
44
|
+
: undefined,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { createSnapshot, describeStore, sanitizePolicies, countActiveBans };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { findMatchingPolicy, matchesPolicy, matchesMethod, matchesPath } = require('./matcher');
|
|
4
|
+
const { buildPolicies, normalizePolicy } = require('./normalize-policy');
|
|
5
|
+
const { getPresetPolicies } = require('./presets');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
findMatchingPolicy,
|
|
9
|
+
matchesPolicy,
|
|
10
|
+
matchesMethod,
|
|
11
|
+
matchesPath,
|
|
12
|
+
buildPolicies,
|
|
13
|
+
normalizePolicy,
|
|
14
|
+
getPresetPolicies,
|
|
15
|
+
};
|