@roboteby/parry 1.1.1 → 2.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.
@@ -10,6 +10,7 @@ const {
10
10
  RequestShapeGuard,
11
11
  } = require('../detectors');
12
12
  const { safeStringify } = require('../utils/normalize');
13
+ const { collectRequestTargets } = require('../express/request-targets');
13
14
  const { severityForThreats } = require('./scoring');
14
15
  const {
15
16
  createThreatEvent,
@@ -71,10 +72,17 @@ async function analyzeRequest(requestData, context) {
71
72
  };
72
73
  }
73
74
 
74
- const threats = [
75
- ...scanApplicationLayerGuards(requestData, config),
76
- ...scanTargets(requestData.targets || [], config),
77
- ];
75
+ const shapeThreat = scanRequestShape(requestData, config);
76
+ const shapeExceeded = Boolean(shapeThreat);
77
+ const targets = shapeExceeded
78
+ ? []
79
+ : requestData.targets || collectRequestTargets(requestData, config);
80
+ const structuredThreats = shapeExceeded
81
+ ? [shapeThreat]
82
+ : scanStructuredGuards(requestData, config, targets);
83
+ const threats = deduplicateThreats(
84
+ shapeExceeded ? structuredThreats : [...structuredThreats, ...scanTargets(targets, config)]
85
+ );
78
86
 
79
87
  if (threats.length > 0) {
80
88
  if (config.rateLimit && rateLimiter) {
@@ -189,7 +197,13 @@ function createRequestEventContext(requestData, timestamp) {
189
197
  }
190
198
 
191
199
  function scanApplicationLayerGuards(requestData, config) {
192
- const threats = [];
200
+ const shapeThreat = scanRequestShape(requestData, config);
201
+ if (shapeThreat) return [shapeThreat];
202
+ const targets = requestData.targets || collectRequestTargets(requestData, config);
203
+ return scanStructuredGuards(requestData, config, targets);
204
+ }
205
+
206
+ function scanRequestShape(requestData, config) {
193
207
  const surfaces = {
194
208
  query: requestData.query || {},
195
209
  params: requestData.params || {},
@@ -198,8 +212,18 @@ function scanApplicationLayerGuards(requestData, config) {
198
212
 
199
213
  if (config.requestShape?.enabled) {
200
214
  const hit = RequestShapeGuard.scan(surfaces, config.requestShape);
201
- if (hit) return [hit];
215
+ if (hit) return hit;
202
216
  }
217
+ return null;
218
+ }
219
+
220
+ function scanStructuredGuards(requestData, config, targets) {
221
+ const threats = [];
222
+ const surfaces = {
223
+ query: requestData.query || {},
224
+ params: requestData.params || {},
225
+ body: requestData.body,
226
+ };
203
227
 
204
228
  if (config.hpp?.enabled) {
205
229
  const hit = HPPDetector.scan(surfaces.query, config.hpp);
@@ -211,8 +235,25 @@ function scanApplicationLayerGuards(requestData, config) {
211
235
  if (hit) threats.push(hit);
212
236
  }
213
237
 
238
+ if (config.nosql) {
239
+ for (const [surface, value] of Object.entries(surfaces)) {
240
+ const hit = NoSQLDetector.inspect(value, {
241
+ rootPath: surface,
242
+ allowedOperators: config.nosqlConfig?.allowedOperators,
243
+ });
244
+ if (hit) {
245
+ threats.push({
246
+ detector: 'NOSQL_INJECTION',
247
+ field: hit.path,
248
+ pattern: hit.pattern,
249
+ reason: 'NoSQL operator or expression detected',
250
+ });
251
+ }
252
+ }
253
+ }
254
+
214
255
  if (config.pathTraversal?.enabled) {
215
- const hit = PathTraversalDetector.scan(requestData.targets || []);
256
+ const hit = PathTraversalDetector.scan(targets);
216
257
  if (hit) threats.push(hit);
217
258
  }
218
259
 
@@ -227,23 +268,42 @@ function scanTargets(targets, config) {
227
268
 
228
269
  if (config.sql) {
229
270
  const hit = SQLInjectionDetector.scan(str);
230
- if (hit) threats.push({ detector: 'SQL_INJECTION', field: label, pattern: hit });
271
+ if (hit) {
272
+ threats.push({
273
+ detector: 'SQL_INJECTION',
274
+ field: label,
275
+ pattern: hit,
276
+ reason: 'SQL injection pattern detected',
277
+ });
278
+ }
231
279
  }
232
280
 
233
281
  if (config.xss) {
234
282
  const hit = XSSDetector.scan(str);
235
- if (hit) threats.push({ detector: 'XSS', field: label, pattern: hit });
236
- }
237
-
238
- if (config.nosql) {
239
- const hit = NoSQLDetector.scan(value);
240
- if (hit) threats.push({ detector: 'NOSQL_INJECTION', field: label, pattern: hit });
283
+ if (hit) {
284
+ threats.push({
285
+ detector: 'XSS',
286
+ field: label,
287
+ pattern: hit,
288
+ reason: 'Cross-site scripting pattern detected',
289
+ });
290
+ }
241
291
  }
242
292
  }
243
293
 
244
294
  return threats;
245
295
  }
246
296
 
297
+ function deduplicateThreats(threats) {
298
+ const seen = new Set();
299
+ return threats.filter((threat) => {
300
+ const key = [threat.detector, threat.field, threat.pattern, threat.reason].join('\u0000');
301
+ if (seen.has(key)) return false;
302
+ seen.add(key);
303
+ return true;
304
+ });
305
+ }
306
+
247
307
  function enrichThreats(threats) {
248
308
  return threats.map((threat) => ({
249
309
  ...threat,
@@ -261,4 +321,4 @@ function toResponseThreat(threat) {
261
321
  return responseThreat;
262
322
  }
263
323
 
264
- module.exports = { analyzeRequest, scanTargets, scanApplicationLayerGuards };
324
+ module.exports = { analyzeRequest, scanTargets, scanApplicationLayerGuards, deduplicateThreats };
package/src/core/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const { RateLimiter } = require('./rateLimiter');
4
- const { ThreatLogger } = require('./logger');
3
+ const { RateLimiter } = require('../rate-limit/limiter');
4
+ const { ThreatLogger } = require('../logger/console-reporter');
5
5
  const { MemoryStore, RedisStore } = require('../stores');
6
6
 
7
7
  module.exports = { RateLimiter, ThreatLogger, MemoryStore, RedisStore };
@@ -12,7 +12,11 @@ const THREAT_SEVERITY = {
12
12
 
13
13
  function severityForThreats(threats) {
14
14
  if (!threats || threats.length === 0) return 'none';
15
- return THREAT_SEVERITY[threats[0].detector] || 'medium';
15
+ const rank = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
16
+ return threats.reduce((highest, threat) => {
17
+ const severity = threat.severity || THREAT_SEVERITY[threat.detector] || 'medium';
18
+ return rank[severity] > rank[highest] ? severity : highest;
19
+ }, 'none');
16
20
  }
17
21
 
18
22
  module.exports = { severityForThreats };
@@ -8,46 +8,66 @@ const {
8
8
 
9
9
  const NoSQLDetector = {
10
10
  /** @param {*} value @returns {string|null} */
11
- scan(value) {
12
- if (value !== null && typeof value === 'object') return _scanObject(value);
13
- if (typeof value === 'string') return _scanString(value);
11
+ scan(value, options = {}) {
12
+ return this.inspect(value, options)?.pattern || null;
13
+ },
14
+
15
+ inspect(value, options = {}) {
16
+ const rootPath = options.rootPath || 'value';
17
+ const allowedOperators = options.allowedOperators || {};
18
+ if (value !== null && typeof value === 'object') {
19
+ return _scanObject(value, rootPath, allowedOperators);
20
+ }
21
+ if (typeof value === 'string') return _scanString(value, rootPath, allowedOperators);
14
22
  return null;
15
23
  },
16
24
  };
17
25
 
18
- function _scanObject(obj, depth = 0) {
19
- if (depth > 6) return null;
26
+ function _scanObject(obj, path, allowedOperators, depth = 0, seen = new WeakSet()) {
27
+ if (depth > 8 || seen.has(obj)) return null;
28
+ seen.add(obj);
20
29
  for (const key of Object.keys(obj)) {
21
- if (NOSQL_DANGEROUS_OPERATORS.has(key)) return `Operador perigoso: ${key}`;
22
- if (NOSQL_SUSPICIOUS_OPERATORS.has(key)) return `Operador suspeito: ${key}`;
30
+ if (NOSQL_DANGEROUS_OPERATORS.has(key)) {
31
+ return { pattern: `Operador perigoso: ${key}`, path: `${path}.${key}` };
32
+ }
33
+ if (NOSQL_SUSPICIOUS_OPERATORS.has(key) && !isAllowed(path, key, allowedOperators)) {
34
+ return { pattern: `Operador suspeito: ${key}`, path: `${path}.${key}` };
35
+ }
23
36
  const val = obj[key];
37
+ const childPath = Array.isArray(obj) ? `${path}[${key}]` : `${path}.${key}`;
24
38
  if (val && typeof val === 'object') {
25
- const nested = _scanObject(val, depth + 1);
39
+ const nested = _scanObject(val, childPath, allowedOperators, depth + 1, seen);
26
40
  if (nested) return nested;
27
41
  }
28
42
  if (typeof val === 'string') {
29
- const hit = _scanString(val);
43
+ const hit = _scanString(val, childPath, allowedOperators);
30
44
  if (hit) return hit;
31
45
  }
32
46
  }
33
47
  return null;
34
48
  }
35
49
 
36
- function _scanString(value) {
50
+ function _scanString(value, path, allowedOperators) {
37
51
  if (!value || value.trim() === '') return null;
38
52
  if (value.trim().startsWith('{') || value.trim().startsWith('[')) {
39
53
  try {
40
54
  const parsed = JSON.parse(value);
41
55
  if (parsed && typeof parsed === 'object') {
42
- const hit = _scanObject(parsed);
56
+ const hit = _scanObject(parsed, path, allowedOperators);
43
57
  if (hit) return hit;
44
58
  }
45
- } catch (_) {}
59
+ } catch (_) {
60
+ // Non-JSON strings are checked by the bounded patterns below.
61
+ }
46
62
  }
47
63
  for (const pattern of NOSQL_STRING_PATTERNS) {
48
- if (pattern.test(value)) return pattern.toString();
64
+ if (pattern.test(value)) return { pattern: pattern.toString(), path };
49
65
  }
50
66
  return null;
51
67
  }
52
68
 
69
+ function isAllowed(path, operator, allowedOperators) {
70
+ return Array.isArray(allowedOperators[path]) && allowedOperators[path].includes(operator);
71
+ }
72
+
53
73
  module.exports = { NoSQLDetector };
@@ -60,7 +60,9 @@ function collectStrings(value, label, seen = new WeakSet()) {
60
60
  for (const [key, child] of Object.entries(value)) {
61
61
  strings.push(...collectStrings(child, `${label}.${key}`, seen));
62
62
  }
63
- } catch (_) {}
63
+ } catch (_) {
64
+ // Ignore objects that cannot expose enumerable values.
65
+ }
64
66
 
65
67
  return strings;
66
68
  }
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const ipaddr = require('ipaddr.js');
4
+ const MAX_PROXY_HOPS = 20;
4
5
 
5
6
  function getClientIp(req, options = {}) {
6
7
  const headers = req.headers || {};
@@ -11,9 +12,22 @@ function getClientIp(req, options = {}) {
11
12
  if (!isTrustedProxy(directIp, options.trustedProxies || [])) return directIp;
12
13
 
13
14
  const forwarded = getHeader(headers, 'x-forwarded-for');
14
- const firstForwarded = forwarded ? forwarded.split(',')[0].trim() : '';
15
+ if (forwarded) {
16
+ const rawHops = String(forwarded).split(',');
17
+ if (rawHops.length === 0 || rawHops.length > MAX_PROXY_HOPS) return directIp;
18
+ const hops = rawHops.map((hop) => parseIp(hop));
19
+ if (hops.some((hop) => !hop)) return directIp;
20
+
21
+ for (let index = hops.length - 1; index >= 0; index -= 1) {
22
+ const hop = hops[index].toString();
23
+ if (!isTrustedProxy(hop, options.trustedProxies || [])) return hop;
24
+ }
25
+
26
+ return hops[0]?.toString() || directIp;
27
+ }
28
+
15
29
  const realIp = getHeader(headers, 'x-real-ip');
16
- return normalizeIp(firstForwarded || realIp || directIp);
30
+ return realIp && parseIp(realIp) ? normalizeIp(realIp) : directIp;
17
31
  }
18
32
 
19
33
  function resolveClientIP(req, options = {}) {
@@ -1,6 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('node:crypto');
3
4
  const { DEFAULTS } = require('../../config/defaults');
5
+ const {
6
+ normalizeHeadersConfig,
7
+ normalizeNoSQLConfig,
8
+ validateParryOptions,
9
+ } = require('../../config/validate');
4
10
  const { analyzeRequest } = require('../core/engine');
5
11
  const { RateLimiter } = require('../rate-limit/limiter');
6
12
  const { ThreatLogger } = require('../logger/console-reporter');
@@ -16,14 +22,14 @@ const {
16
22
  observeAuthenticationResult,
17
23
  } = require('../brute-force');
18
24
  const { resolveClientIP } = require('./ip-resolver');
19
- const { collectRequestTargets } = require('./request-targets');
20
25
  const { setRateLimitHeaders, respond } = require('./response');
21
26
 
22
27
  /**
23
28
  * Detects SQL Injection, XSS and NoSQL Injection in real-time.
24
29
  * Applies intelligent Rate Limiting with automatic banning for suspicious behavior.
25
30
  *
26
- * @param {import('../../types/index').Parry_DDoSOptions} options
31
+ * @deprecated Use createParry() instead.
32
+ * @param {import('../../types/index').ParryOptions} options
27
33
  * @returns {import('express').RequestHandler}
28
34
  */
29
35
  function Parry_DDoS(options = {}) {
@@ -101,7 +107,6 @@ async function handleRequest(req, res, next, context) {
101
107
  query: req.query || {},
102
108
  params: req.params || {},
103
109
  body: req.body,
104
- targets: collectRequestTargets(req, config.maxObjectDepth),
105
110
  requestId,
106
111
  userAgent: getHeader(req.headers || {}, 'user-agent'),
107
112
  };
@@ -175,6 +180,7 @@ async function handleRequest(req, res, next, context) {
175
180
  }
176
181
 
177
182
  function mergeConfig(options) {
183
+ validateParryOptions(options);
178
184
  const config = { ...DEFAULTS, ...options };
179
185
 
180
186
  for (const key of ['hpp', 'prototypePollution', 'pathTraversal', 'requestShape']) {
@@ -198,6 +204,9 @@ function mergeConfig(options) {
198
204
  config.storeFailureMode =
199
205
  options.storeFailureMode === 'fail-closed' ? 'fail-closed' : 'fail-open';
200
206
  config.policies = buildPolicies(options);
207
+ config.headers = normalizeHeadersConfig(options.headers);
208
+ config.nosqlConfig = normalizeNoSQLConfig(options.nosql);
209
+ config.nosql = config.nosqlConfig.enabled;
201
210
  config.bruteForce =
202
211
  options.bruteForce === false
203
212
  ? false
@@ -366,7 +375,7 @@ function resolveRequestId(req, res, config) {
366
375
  }
367
376
 
368
377
  function createRequestId() {
369
- return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
378
+ return `req_${crypto.randomUUID()}`;
370
379
  }
371
380
 
372
381
  function getHeader(headers, name) {
@@ -1,32 +1,42 @@
1
1
  'use strict';
2
2
 
3
- const { SENSITIVE_HEADERS } = require('../../constants/patterns');
4
- const { flattenObject } = require('../utils/flatten');
5
3
  const { normalizeTarget } = require('../utils/normalize');
6
4
 
7
- function collectRequestTargets(req, maxDepth) {
5
+ function collectRequestTargets(req, options = {}) {
8
6
  const targets = [];
9
7
  const headers = req.headers || {};
8
+ const maxDepth = typeof options === 'number' ? options : (options.maxObjectDepth ?? 8);
9
+ const headersToScan =
10
+ typeof options === 'number' || !options.headers
11
+ ? ['user-agent', 'referer', 'x-forwarded-for', 'cookie']
12
+ : options.headers.scan;
13
+ const seen = new WeakSet();
10
14
 
11
15
  const add = (label, value) => {
12
- if (value != null) targets.push(normalizeTarget({ label, value }));
16
+ if (value == null) return;
17
+ const type = typeof value;
18
+ if (type === 'string' || type === 'number' || type === 'boolean' || type === 'bigint') {
19
+ targets.push(normalizeTarget({ label, value }));
20
+ }
13
21
  };
14
22
 
15
- if (req.query && typeof req.query === 'object') {
16
- for (const [key, value] of Object.entries(req.query)) add(`query.${key}`, value);
17
- }
23
+ const collect = (value, label, depth) => {
24
+ if (value == null || typeof value !== 'object') return add(label, value);
25
+ if (depth > maxDepth || seen.has(value)) return;
26
+ seen.add(value);
18
27
 
19
- if (req.params && typeof req.params === 'object') {
20
- for (const [key, value] of Object.entries(req.params)) add(`params.${key}`, value);
21
- }
28
+ for (const [key, child] of Object.entries(value)) {
29
+ collect(child, Array.isArray(value) ? `${label}[${key}]` : `${label}.${key}`, depth + 1);
30
+ }
31
+ };
22
32
 
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
- }
33
+ collect(req.query || {}, 'query', 0);
34
+ collect(req.params || {}, 'params', 0);
35
+ collect(req.body, 'body', 0);
27
36
 
28
- for (const header of SENSITIVE_HEADERS) {
29
- if (headers[header]) add(`header.${header}`, headers[header]);
37
+ for (const header of headersToScan || []) {
38
+ const key = Object.keys(headers).find((name) => name.toLowerCase() === header);
39
+ if (key) collect(headers[key], `header.${header}`, 0);
30
40
  }
31
41
 
32
42
  return targets;
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { Parry_DDoS, createParry } = require('./middleware');
3
+ const { Parry_DDoS, createParry } = require('./express/middleware');
4
4
  const { RateLimiter, ThreatLogger } = require('./core');
5
5
  const { MemoryStore, RedisStore } = require('./stores');
6
6
  const { EventBus, MemoryEventStore } = require('./events');
@@ -4,7 +4,9 @@ function decodeSqlValue(input) {
4
4
  let result = input;
5
5
  try {
6
6
  result = decodeURIComponent(result.replace(/\+/g, ' '));
7
- } catch (_) {}
7
+ } catch (_) {
8
+ // Preserve malformed encodings for the detector's raw checks.
9
+ }
8
10
  return decodeHtmlEntities(result, { hex: false });
9
11
  }
10
12
 
@@ -2,7 +2,9 @@
2
2
 
3
3
  function safeStringify(value) {
4
4
  if (typeof value === 'string') return value;
5
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
5
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
6
+ return String(value);
7
+ }
6
8
  try {
7
9
  return JSON.stringify(value);
8
10
  } catch {
@@ -0,0 +1,26 @@
1
+ export {
2
+ createParryAdminRouter,
3
+ AdminRouterOptions,
4
+ AdminAuthMode,
5
+ AdminAuthConfig,
6
+ AdminAuthStrategyConfig,
7
+ AdminCombinedAuthConfig,
8
+ ParryAdminContext,
9
+ } from './index';
10
+ import { Request, RequestHandler } from 'express';
11
+ import { AdminAuthConfig, ParryAdminContext } from './index';
12
+
13
+ export declare function resolveParryContext(parry: unknown): Record<string, unknown> | null;
14
+ export declare function createAdminAuthMiddleware(
15
+ config: AdminAuthConfig,
16
+ context?: Record<string, unknown>
17
+ ): RequestHandler;
18
+ export declare function authenticateAdminRequest(
19
+ req: Request,
20
+ config: AdminAuthConfig,
21
+ context?: Record<string, unknown>
22
+ ): Promise<{ ok: boolean; statusCode?: number; admin?: ParryAdminContext }>;
23
+ export declare function requireAdminAuth(
24
+ options?: Record<string, unknown>,
25
+ context?: Record<string, unknown> | null
26
+ ): RequestHandler;
@@ -0,0 +1,44 @@
1
+ import { PolicyConfig, ThreatEvent } from './index';
2
+
3
+ export interface BuiltKey {
4
+ type: string;
5
+ value: string;
6
+ key: string;
7
+ }
8
+
9
+ export declare function createBruteForceContext(
10
+ context: Record<string, unknown>
11
+ ): Record<string, unknown>;
12
+ export declare function attachParryRequestApi(req: object, context: Record<string, unknown>): void;
13
+ export declare function checkBruteForceBlock(
14
+ context: Record<string, unknown>
15
+ ): Promise<Record<string, unknown>>;
16
+ export declare function observeAuthenticationResult(context: Record<string, unknown>): void;
17
+ export declare function finalizeAuthenticationResult(
18
+ context: Record<string, unknown>
19
+ ): Promise<unknown>;
20
+ export declare function createBruteForceEvent(context: Record<string, unknown>): ThreatEvent;
21
+ export declare function buildBruteForceKeys(
22
+ policy: PolicyConfig,
23
+ requestData: Record<string, unknown>
24
+ ): BuiltKey[];
25
+ export declare function buildRouteRateLimitKey(
26
+ policy: PolicyConfig,
27
+ requestData: Record<string, unknown>
28
+ ): BuiltKey | null;
29
+ export declare function buildKey(
30
+ policyName: string,
31
+ spec: string | ((requestData: Record<string, unknown>) => unknown),
32
+ requestData: Record<string, unknown>,
33
+ namespace: string
34
+ ): BuiltKey | null;
35
+ export declare function resolveValue(
36
+ path: string,
37
+ requestData: Record<string, unknown>
38
+ ): string | null;
39
+ export declare function createBlockedResponse(blocked: Record<string, unknown>): {
40
+ statusCode: number;
41
+ headers: { 'Retry-After': number };
42
+ body: { error: string; code: string; retryAfter: number };
43
+ };
44
+ export declare function retryAfterSeconds(blocked: Record<string, unknown>): number;
@@ -0,0 +1 @@
1
+ export { RateLimiter, ThreatLogger, MemoryStore, RedisStore } from './index';
@@ -0,0 +1,9 @@
1
+ export {
2
+ SQLInjectionDetector,
3
+ XSSDetector,
4
+ NoSQLDetector,
5
+ HPPDetector,
6
+ PrototypePollutionDetector,
7
+ PathTraversalDetector,
8
+ RequestShapeGuard,
9
+ } from './index';
@@ -0,0 +1,16 @@
1
+ export { EventBus, MemoryEventStore } from './index';
2
+ export { ThreatEvent, ThreatLogEntry, EventFilters, EventPage } from './index';
3
+ import { ThreatEvent, ThreatLogEntry } from './index';
4
+
5
+ export declare function createThreatEvent(
6
+ input?: Partial<ThreatEvent> | ThreatLogEntry
7
+ ): ThreatEvent;
8
+ export declare function createStoreErrorEvent(
9
+ error: unknown,
10
+ context?: Record<string, unknown>
11
+ ): ThreatEvent;
12
+ export declare function createHookErrorEvent(
13
+ error: unknown,
14
+ event?: Partial<ThreatEvent>
15
+ ): ThreatEvent;
16
+ export declare function sanitizeEvent<T>(event: T): T;