@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +284 -0
  4. package/config/defaults.js +65 -0
  5. package/constants/patterns.js +77 -0
  6. package/package.json +89 -0
  7. package/src/admin/admin-router.js +106 -0
  8. package/src/admin/auth/admin-auth.js +176 -0
  9. package/src/admin/auth/index.js +13 -0
  10. package/src/admin/auth/strategies/alb-auth.js +49 -0
  11. package/src/admin/auth/strategies/cloudflare-access.js +34 -0
  12. package/src/admin/auth/strategies/combined.js +50 -0
  13. package/src/admin/auth/strategies/ip-allowlist.js +13 -0
  14. package/src/admin/auth/strategies/none.js +20 -0
  15. package/src/admin/auth/strategies/token.js +25 -0
  16. package/src/admin/auth/strategies/trusted-proxy.js +52 -0
  17. package/src/admin/auth/utils/constant-time.js +18 -0
  18. package/src/admin/auth/utils/external-identity.js +156 -0
  19. package/src/admin/auth/utils/header-utils.js +39 -0
  20. package/src/admin/auth/utils/result.js +39 -0
  21. package/src/admin/ban-normalizer.js +98 -0
  22. package/src/admin/index.js +12 -0
  23. package/src/admin/response.js +41 -0
  24. package/src/brute-force/brute-force-guard.js +268 -0
  25. package/src/brute-force/index.js +32 -0
  26. package/src/brute-force/key-builder.js +164 -0
  27. package/src/brute-force/result.js +35 -0
  28. package/src/core/engine.js +264 -0
  29. package/src/core/index.js +7 -0
  30. package/src/core/logger.js +3 -0
  31. package/src/core/rate-limit-result.js +13 -0
  32. package/src/core/rateLimiter.js +3 -0
  33. package/src/core/scoring.js +18 -0
  34. package/src/core/threat-event.js +69 -0
  35. package/src/detectors/hpp.js +30 -0
  36. package/src/detectors/index.js +19 -0
  37. package/src/detectors/nosql.js +53 -0
  38. package/src/detectors/path-traversal.js +72 -0
  39. package/src/detectors/prototype-pollution.js +69 -0
  40. package/src/detectors/request-shape.js +76 -0
  41. package/src/detectors/sql.js +18 -0
  42. package/src/detectors/xss.js +18 -0
  43. package/src/events/event-bus.js +51 -0
  44. package/src/events/index.js +19 -0
  45. package/src/events/memory-event-store.js +64 -0
  46. package/src/events/sanitize-event.js +54 -0
  47. package/src/events/threat-event.js +174 -0
  48. package/src/express/ip-resolver.js +109 -0
  49. package/src/express/middleware.js +379 -0
  50. package/src/express/request-targets.js +35 -0
  51. package/src/express/response.js +14 -0
  52. package/src/index.js +41 -0
  53. package/src/logger/console-reporter.js +75 -0
  54. package/src/middleware/index.js +7 -0
  55. package/src/middleware/parry_ddos.js +3 -0
  56. package/src/observability/index.js +6 -0
  57. package/src/observability/metrics.js +61 -0
  58. package/src/observability/snapshot.js +48 -0
  59. package/src/policies/index.js +15 -0
  60. package/src/policies/matcher.js +48 -0
  61. package/src/policies/normalize-policy.js +94 -0
  62. package/src/policies/presets.js +34 -0
  63. package/src/rate-limit/keys.js +7 -0
  64. package/src/rate-limit/limiter.js +124 -0
  65. package/src/stores/README.md +51 -0
  66. package/src/stores/index.js +6 -0
  67. package/src/stores/memory-store.js +278 -0
  68. package/src/stores/redis-store.js +349 -0
  69. package/src/utils/decode.js +56 -0
  70. package/src/utils/flatten.js +27 -0
  71. package/src/utils/normalize.js +21 -0
  72. package/types/index.d.ts +555 -0
@@ -0,0 +1,349 @@
1
+ 'use strict';
2
+
3
+ const INCREMENT_WITH_TTL_SCRIPT = `
4
+ local current = redis.call("INCR", KEYS[1])
5
+ if current == 1 then
6
+ redis.call("PEXPIRE", KEYS[1], ARGV[1])
7
+ end
8
+ local ttl = redis.call("PTTL", KEYS[1])
9
+ return { current, ttl }
10
+ `;
11
+
12
+ class RedisStore {
13
+ constructor(options = {}) {
14
+ if (!options.client) {
15
+ throw new Error('RedisStore requires a Redis client.');
16
+ }
17
+
18
+ this.client = options.client;
19
+ this.prefix = options.prefix || 'parry';
20
+ this.closeClient = options.closeClient === true;
21
+
22
+ this._validateClient();
23
+ }
24
+
25
+ async incrementRateLimit(key, windowMs) {
26
+ return this._incrementWithTtl(this._key('rl', key), normalizeKey(key), windowMs);
27
+ }
28
+
29
+ async getRateLimit(key) {
30
+ const redisKey = this._key('rl', key);
31
+ const [count, ttlMs] = await Promise.all([this.client.get(redisKey), this._pTTL(redisKey)]);
32
+
33
+ return counterResult(normalizeKey(key), Number(count || 0), ttlMs);
34
+ }
35
+
36
+ async resetRateLimit(key) {
37
+ return this.client.del(this._key('rl', key));
38
+ }
39
+
40
+ async ban(key, ttlMs, metadata = {}) {
41
+ const normalizedKey = normalizeKey(key);
42
+ const createdAt = Date.now();
43
+ const banExpiresAt = createdAt + ttlMs;
44
+ const payload = JSON.stringify({ metadata, createdAt, banExpiresAt });
45
+
46
+ await this.client.set(this._key('ban', normalizedKey), payload, { PX: ttlMs });
47
+ await this._indexAdd('bans', normalizedKey);
48
+
49
+ return { key: normalizedKey, banned: true, createdAt, banExpiresAt, metadata };
50
+ }
51
+
52
+ async isBanned(key) {
53
+ const normalizedKey = normalizeKey(key);
54
+ const redisKey = this._key('ban', normalizedKey);
55
+ const value = await this.client.get(redisKey);
56
+
57
+ if (!value) return { key: normalizedKey, banned: false, banExpiresAt: null, metadata: null };
58
+
59
+ const ttlMs = await this._pTTL(redisKey);
60
+ if (ttlMs <= 0) {
61
+ await this.client.del(redisKey);
62
+ await this._indexRemove('bans', normalizedKey);
63
+ return { key: normalizedKey, banned: false, banExpiresAt: null, metadata: null };
64
+ }
65
+
66
+ const parsed = parseJson(value);
67
+ return {
68
+ key: normalizedKey,
69
+ banned: true,
70
+ createdAt: parsed?.createdAt || null,
71
+ banExpiresAt: parsed?.banExpiresAt || Date.now() + ttlMs,
72
+ metadata: parsed?.metadata || null,
73
+ };
74
+ }
75
+
76
+ async unban(key) {
77
+ const normalizedKey = normalizeKey(key);
78
+ await this._indexRemove('bans', normalizedKey);
79
+ return this.client.del([
80
+ this._key('ban', normalizedKey),
81
+ this._key('suspicious', normalizedKey),
82
+ ]);
83
+ }
84
+
85
+ async recordSuspicious(key, ttlMs, metadata = {}) {
86
+ const result = await this._incrementWithTtl(
87
+ this._key('suspicious', key),
88
+ normalizeKey(key),
89
+ ttlMs
90
+ );
91
+ result.metadata = metadata;
92
+ return result;
93
+ }
94
+
95
+ async incrementCounter(key, ttlMs, metadata = {}) {
96
+ const result = await this._incrementWithTtl(this._counterKey(key), normalizeKey(key), ttlMs);
97
+ result.metadata = metadata;
98
+ return result;
99
+ }
100
+
101
+ async getCounter(key) {
102
+ const normalizedKey = normalizeKey(key);
103
+ const redisKey = this._counterKey(normalizedKey);
104
+ const [count, ttlMs] = await Promise.all([this.client.get(redisKey), this._pTTL(redisKey)]);
105
+
106
+ return counterResult(normalizedKey, Number(count || 0), ttlMs);
107
+ }
108
+
109
+ async resetCounter(key) {
110
+ return this.client.del(this._counterKey(key));
111
+ }
112
+
113
+ async blockKey(key, ttlMs, metadata = {}) {
114
+ const normalizedKey = normalizeKey(key);
115
+ const createdAt = Date.now();
116
+ const blockExpiresAt = createdAt + ttlMs;
117
+ const payload = JSON.stringify({ metadata, createdAt, blockExpiresAt });
118
+
119
+ await this.client.set(this._blockKey(normalizedKey), payload, { PX: ttlMs });
120
+ await this._indexAdd('blocks', normalizedKey);
121
+
122
+ return { key: normalizedKey, blocked: true, createdAt, blockExpiresAt, metadata };
123
+ }
124
+
125
+ async isBlocked(key) {
126
+ const normalizedKey = normalizeKey(key);
127
+ const redisKey = this._blockKey(normalizedKey);
128
+ const value = await this.client.get(redisKey);
129
+
130
+ if (!value) return { key: normalizedKey, blocked: false, blockExpiresAt: null, metadata: null };
131
+
132
+ const ttlMs = await this._pTTL(redisKey);
133
+ if (ttlMs <= 0) {
134
+ await this.client.del(redisKey);
135
+ await this._indexRemove('blocks', normalizedKey);
136
+ return { key: normalizedKey, blocked: false, blockExpiresAt: null, metadata: null };
137
+ }
138
+
139
+ const parsed = parseJson(value);
140
+ return {
141
+ key: normalizedKey,
142
+ blocked: true,
143
+ createdAt: parsed?.createdAt || null,
144
+ blockExpiresAt: parsed?.blockExpiresAt || Date.now() + ttlMs,
145
+ metadata: parsed?.metadata || null,
146
+ };
147
+ }
148
+
149
+ async unblockKey(key) {
150
+ const normalizedKey = normalizeKey(key);
151
+ await this._indexRemove('blocks', normalizedKey);
152
+ return this.client.del(this._blockKey(normalizedKey));
153
+ }
154
+
155
+ async listBans() {
156
+ const keys = await this._readIndex('bans');
157
+ const entries = [];
158
+
159
+ for (const key of keys) {
160
+ const ban = await this.isBanned(key);
161
+ if (!ban.banned) continue;
162
+ entries.push({
163
+ key: ban.key,
164
+ createdAt: ban.createdAt,
165
+ banExpiresAt: ban.banExpiresAt,
166
+ ttlMs: ban.banExpiresAt ? Math.max(0, ban.banExpiresAt - Date.now()) : null,
167
+ metadata: ban.metadata,
168
+ });
169
+ }
170
+
171
+ return entries;
172
+ }
173
+
174
+ async listBlocks() {
175
+ const keys = await this._readIndex('blocks');
176
+ const entries = [];
177
+
178
+ for (const key of keys) {
179
+ const block = await this.isBlocked(key);
180
+ if (!block.blocked) continue;
181
+ entries.push({
182
+ key: block.key,
183
+ createdAt: block.createdAt,
184
+ blockExpiresAt: block.blockExpiresAt,
185
+ ttlMs: block.blockExpiresAt ? Math.max(0, block.blockExpiresAt - Date.now()) : null,
186
+ metadata: block.metadata,
187
+ });
188
+ }
189
+
190
+ return entries;
191
+ }
192
+
193
+ getStoreInfo() {
194
+ return {
195
+ type: 'redis',
196
+ prefix: this.prefix,
197
+ supportsAdminListing: true,
198
+ };
199
+ }
200
+
201
+ async close() {
202
+ if (!this.closeClient) return;
203
+ if (typeof this.client.quit === 'function') return this.client.quit();
204
+ if (typeof this.client.disconnect === 'function') return this.client.disconnect();
205
+ }
206
+
207
+ _validateClient() {
208
+ const hasRequired =
209
+ hasMethod(this.client, 'get') &&
210
+ hasMethod(this.client, 'set') &&
211
+ hasMethod(this.client, 'del') &&
212
+ hasMethod(this.client, 'incr') &&
213
+ (hasMethod(this.client, 'pExpire') || hasMethod(this.client, 'pexpire')) &&
214
+ (hasMethod(this.client, 'pTTL') || hasMethod(this.client, 'pttl')) &&
215
+ hasMethod(this.client, 'sAdd') &&
216
+ hasMethod(this.client, 'sRem') &&
217
+ (hasMethod(this.client, 'sScan') || hasMethod(this.client, 'sMembers'));
218
+
219
+ if (!hasRequired) {
220
+ throw new Error(
221
+ 'RedisStore requires a Redis client with get, set, del, incr, pExpire/pTTL and Set index support.'
222
+ );
223
+ }
224
+
225
+ if (!hasMethod(this.client, 'eval') && !hasMethod(this.client, 'multi')) {
226
+ throw new Error('RedisStore requires a Redis client with eval or multi/exec support.');
227
+ }
228
+ }
229
+
230
+ async _incrementWithTtl(redisKey, publicKey, windowMs) {
231
+ if (hasMethod(this.client, 'eval')) {
232
+ const result = await this.client.eval(INCREMENT_WITH_TTL_SCRIPT, {
233
+ keys: [redisKey],
234
+ arguments: [String(windowMs)],
235
+ });
236
+ const [count, ttlMs] = normalizeRedisArray(result);
237
+ return counterResult(publicKey, count, ttlMs);
238
+ }
239
+
240
+ await this.client.set(redisKey, '0', { PX: windowMs, NX: true });
241
+
242
+ const multi = this.client.multi();
243
+ multi.incr(redisKey);
244
+ if (typeof multi.pTTL === 'function') multi.pTTL(redisKey);
245
+ else multi.pttl(redisKey);
246
+
247
+ const result = await multi.exec();
248
+ const [count, ttlMs] = normalizeRedisArray(result);
249
+ return counterResult(publicKey, count, ttlMs);
250
+ }
251
+
252
+ _key(type, key) {
253
+ return `${this.prefix}:${type}:${normalizeKey(key)}`;
254
+ }
255
+
256
+ _indexKey(type) {
257
+ return `${this.prefix}:index:${type}`;
258
+ }
259
+
260
+ _indexAdd(type, key) {
261
+ return this.client.sAdd(this._indexKey(type), normalizeKey(key));
262
+ }
263
+
264
+ _indexRemove(type, key) {
265
+ return this.client.sRem(this._indexKey(type), normalizeKey(key));
266
+ }
267
+
268
+ async _readIndex(type) {
269
+ const key = this._indexKey(type);
270
+ if (hasMethod(this.client, 'sScan')) {
271
+ const members = [];
272
+ let cursor = 0;
273
+ do {
274
+ const result = await this.client.sScan(key, cursor, { COUNT: 100 });
275
+ cursor = normalizeCursor(result);
276
+ members.push(...normalizeMembers(result));
277
+ } while (cursor !== 0);
278
+ return members;
279
+ }
280
+
281
+ return this.client.sMembers(key);
282
+ }
283
+
284
+ _counterKey(key) {
285
+ return `${this.prefix}:${normalizeKey(key)}:count`;
286
+ }
287
+
288
+ _blockKey(key) {
289
+ return `${this.prefix}:${normalizeKey(key)}:block`;
290
+ }
291
+
292
+ _pExpire(key, ttlMs) {
293
+ const fn = this.client.pExpire || this.client.pexpire;
294
+ return fn.call(this.client, key, ttlMs);
295
+ }
296
+
297
+ _pTTL(key) {
298
+ const fn = this.client.pTTL || this.client.pttl;
299
+ return fn.call(this.client, key);
300
+ }
301
+ }
302
+
303
+ function hasMethod(target, method) {
304
+ return typeof target[method] === 'function';
305
+ }
306
+
307
+ function normalizeKey(key) {
308
+ return String(key || 'unknown');
309
+ }
310
+
311
+ function counterResult(key, count, ttlMs) {
312
+ const safeTtl = Number(ttlMs) > 0 ? Number(ttlMs) : 0;
313
+ return {
314
+ key,
315
+ count: Number(count || 0),
316
+ resetAt: safeTtl > 0 ? Date.now() + safeTtl : null,
317
+ ttlMs: safeTtl,
318
+ };
319
+ }
320
+
321
+ function normalizeRedisArray(result) {
322
+ if (!Array.isArray(result)) return [Number(result || 0), 0];
323
+
324
+ if (Array.isArray(result[0])) {
325
+ return result.map((item) => Number(item[1] || 0));
326
+ }
327
+
328
+ return result.map((item) => Number(item || 0));
329
+ }
330
+
331
+ function normalizeCursor(result) {
332
+ if (Array.isArray(result)) return Number(result[0] || 0);
333
+ return Number(result?.cursor || 0);
334
+ }
335
+
336
+ function normalizeMembers(result) {
337
+ if (Array.isArray(result)) return result[1] || [];
338
+ return result?.members || [];
339
+ }
340
+
341
+ function parseJson(value) {
342
+ try {
343
+ return JSON.parse(value);
344
+ } catch (_error) {
345
+ return null;
346
+ }
347
+ }
348
+
349
+ module.exports = { RedisStore };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ function decodeSqlValue(input) {
4
+ let result = input;
5
+ try {
6
+ result = decodeURIComponent(result.replace(/\+/g, ' '));
7
+ } catch (_) {}
8
+ return decodeHtmlEntities(result, { hex: false });
9
+ }
10
+
11
+ function decodeXssValue(input) {
12
+ let result = input;
13
+ for (let i = 0; i < 3; i++) {
14
+ try {
15
+ const next = decodeURIComponent(result.replace(/\+/g, ' '));
16
+ if (next === result) break;
17
+ result = next;
18
+ } catch (_) {
19
+ break;
20
+ }
21
+ }
22
+ result = result.replace(/[\u200B-\u200D\uFEFF\u00AD]/g, '');
23
+ return decodeHtmlEntities(result, { hex: true });
24
+ }
25
+
26
+ function decodeHtmlEntities(input, options = {}) {
27
+ let result = input
28
+ .replace(/&amp;/gi, '&')
29
+ .replace(/&lt;/gi, '<')
30
+ .replace(/&gt;/gi, '>')
31
+ .replace(/&quot;/gi, '"')
32
+ .replace(/&#x27;/gi, "'")
33
+ .replace(/&#(\d+);/gi, (_, c) => String.fromCharCode(Number(c)));
34
+
35
+ if (options.hex) {
36
+ result = result.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
37
+ }
38
+
39
+ return result;
40
+ }
41
+
42
+ function decodeUrlValue(input, maxPasses = 2) {
43
+ let result = input;
44
+ for (let i = 0; i < maxPasses; i++) {
45
+ try {
46
+ const next = decodeURIComponent(result.replace(/\+/g, ' '));
47
+ if (next === result) break;
48
+ result = next;
49
+ } catch (_) {
50
+ break;
51
+ }
52
+ }
53
+ return result;
54
+ }
55
+
56
+ module.exports = { decodeSqlValue, decodeXssValue, decodeHtmlEntities, decodeUrlValue };
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ function flattenObject(obj, prefix, maxDepth, depth = 0, seen = new WeakSet()) {
4
+ if (depth >= maxDepth) return [];
5
+ if (!obj || typeof obj !== 'object') return [];
6
+ if (seen.has(obj)) return [];
7
+ seen.add(obj);
8
+
9
+ const targets = [];
10
+ try {
11
+ for (const [key, val] of Object.entries(obj)) {
12
+ const path = `${prefix}.${key}`;
13
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
14
+ targets.push({ label: path, value: val });
15
+ targets.push(...flattenObject(val, path, maxDepth, depth + 1, seen));
16
+ } else {
17
+ targets.push({ label: path, value: val });
18
+ }
19
+ }
20
+ } catch (_) {
21
+ return targets;
22
+ }
23
+
24
+ return targets;
25
+ }
26
+
27
+ module.exports = { flattenObject };
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ function safeStringify(value) {
4
+ if (typeof value === 'string') return value;
5
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
6
+ try {
7
+ return JSON.stringify(value);
8
+ } catch {
9
+ return '';
10
+ }
11
+ }
12
+
13
+ function normalizeTarget(target) {
14
+ return {
15
+ label: target.label,
16
+ value: target.value,
17
+ stringValue: safeStringify(target.value),
18
+ };
19
+ }
20
+
21
+ module.exports = { safeStringify, normalizeTarget };