@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,18 @@
1
+ 'use strict';
2
+
3
+ const { SQL_PATTERNS } = require('../../constants/patterns');
4
+ const { decodeSqlValue } = require('../utils/decode');
5
+
6
+ const SQLInjectionDetector = {
7
+ /** @param {string} value @returns {string|null} */
8
+ scan(value) {
9
+ if (typeof value !== 'string' || value.trim() === '') return null;
10
+ const decoded = decodeSqlValue(value);
11
+ for (const pattern of SQL_PATTERNS) {
12
+ if (pattern.test(decoded)) return pattern.toString();
13
+ }
14
+ return null;
15
+ },
16
+ };
17
+
18
+ module.exports = { SQLInjectionDetector };
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ const { XSS_PATTERNS } = require('../../constants/patterns');
4
+ const { decodeXssValue } = require('../utils/decode');
5
+
6
+ const XSSDetector = {
7
+ /** @param {string} value @returns {string|null} */
8
+ scan(value) {
9
+ if (typeof value !== 'string' || value.trim() === '') return null;
10
+ const decoded = decodeXssValue(value);
11
+ for (const pattern of XSS_PATTERNS) {
12
+ if (pattern.test(decoded)) return pattern.toString();
13
+ }
14
+ return null;
15
+ },
16
+ };
17
+
18
+ module.exports = { XSSDetector };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const { createThreatEvent, createHookErrorEvent } = require('./threat-event');
4
+ const { MemoryEventStore } = require('./memory-event-store');
5
+
6
+ class EventBus {
7
+ constructor(options = {}) {
8
+ this.eventStore = options.eventStore || new MemoryEventStore(options);
9
+ this.listeners = [];
10
+ }
11
+
12
+ emitThreat(event, context = {}) {
13
+ const normalized = createThreatEvent(event);
14
+ this._record(normalized);
15
+ this._notify(normalized, context);
16
+ return normalized;
17
+ }
18
+
19
+ onThreat(listener) {
20
+ if (typeof listener !== 'function') return () => {};
21
+ this.listeners.push(listener);
22
+ return () => {
23
+ this.listeners = this.listeners.filter((item) => item !== listener);
24
+ };
25
+ }
26
+
27
+ getRecentEvents(options) {
28
+ return this.eventStore.getRecentEvents(options);
29
+ }
30
+
31
+ getEventById(id) {
32
+ return this.eventStore.getById(id);
33
+ }
34
+
35
+ _record(event) {
36
+ this.eventStore.add(event);
37
+ }
38
+
39
+ _notify(event, context) {
40
+ for (const listener of this.listeners) {
41
+ try {
42
+ listener(event, context.req, context.res);
43
+ } catch (error) {
44
+ const hookError = createHookErrorEvent(error, event);
45
+ this._record(hookError);
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ module.exports = { EventBus };
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const { EventBus } = require('./event-bus');
4
+ const { MemoryEventStore } = require('./memory-event-store');
5
+ const {
6
+ createThreatEvent,
7
+ createStoreErrorEvent,
8
+ createHookErrorEvent,
9
+ } = require('./threat-event');
10
+ const { sanitizeEvent } = require('./sanitize-event');
11
+
12
+ module.exports = {
13
+ EventBus,
14
+ MemoryEventStore,
15
+ createThreatEvent,
16
+ createStoreErrorEvent,
17
+ createHookErrorEvent,
18
+ sanitizeEvent,
19
+ };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ class MemoryEventStore {
4
+ constructor(options = {}) {
5
+ this.maxEvents = options.maxEvents || 500;
6
+ this.events = [];
7
+ }
8
+
9
+ add(event) {
10
+ this.events.unshift(event);
11
+ if (this.events.length > this.maxEvents) {
12
+ this.events.length = this.maxEvents;
13
+ }
14
+ return event;
15
+ }
16
+
17
+ getRecentEvents(options = {}) {
18
+ const limit = clampNumber(options.limit, 50, 1, this.maxEvents);
19
+ const offset = clampNumber(options.offset, 0, 0, this.maxEvents);
20
+ const filtered = this.events.filter((event) => matchesFilters(event, options));
21
+
22
+ return {
23
+ data: filtered.slice(offset, offset + limit),
24
+ pagination: {
25
+ limit,
26
+ offset,
27
+ total: filtered.length,
28
+ },
29
+ };
30
+ }
31
+
32
+ getById(id) {
33
+ return this.events.find((event) => event.id === id) || null;
34
+ }
35
+
36
+ clear() {
37
+ this.events = [];
38
+ }
39
+ }
40
+
41
+ function matchesFilters(event, filters) {
42
+ for (const key of ['type', 'severity', 'action', 'ip', 'path', 'policyName']) {
43
+ if (filters[key] && event[key] !== filters[key]) return false;
44
+ }
45
+
46
+ if (
47
+ filters.detector &&
48
+ event.detector !== filters.detector &&
49
+ event.detectorSlug !== filters.detector &&
50
+ event.detectorType !== filters.detector
51
+ ) {
52
+ return false;
53
+ }
54
+
55
+ return true;
56
+ }
57
+
58
+ function clampNumber(value, fallback, min, max) {
59
+ const parsed = Number(value);
60
+ if (!Number.isFinite(parsed)) return fallback;
61
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
62
+ }
63
+
64
+ module.exports = { MemoryEventStore };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const SENSITIVE_KEYS = new Set([
4
+ 'authorization',
5
+ 'cookie',
6
+ 'credentials',
7
+ 'password',
8
+ 'secret',
9
+ 'token',
10
+ ]);
11
+
12
+ function sanitizeEvent(value) {
13
+ return sanitizeValue(value, new WeakSet());
14
+ }
15
+
16
+ function sanitizeValue(value, seen) {
17
+ if (value == null) return value;
18
+ if (typeof value !== 'object') return value;
19
+ if (seen.has(value)) return '[Circular]';
20
+ seen.add(value);
21
+
22
+ if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, seen));
23
+
24
+ const output = {};
25
+ for (const [key, entry] of Object.entries(value)) {
26
+ if (isSensitiveKey(key)) {
27
+ output[key] = '[REDACTED]';
28
+ continue;
29
+ }
30
+
31
+ if (key === 'body' || key === 'rawBody') {
32
+ output[key] = '[REDACTED]';
33
+ continue;
34
+ }
35
+
36
+ output[key] = sanitizeValue(entry, seen);
37
+ }
38
+
39
+ return output;
40
+ }
41
+
42
+ function isSensitiveKey(key) {
43
+ const normalized = String(key || '').toLowerCase();
44
+ return [...SENSITIVE_KEYS].some((sensitive) => normalized.includes(sensitive));
45
+ }
46
+
47
+ function sanitizeUserAgent(value) {
48
+ if (!value) return undefined;
49
+ return String(value)
50
+ .replace(/[\r\n]/g, ' ')
51
+ .slice(0, 200);
52
+ }
53
+
54
+ module.exports = { sanitizeEvent, sanitizeUserAgent, isSensitiveKey };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ const { sanitizeEvent, sanitizeUserAgent } = require('./sanitize-event');
4
+
5
+ let eventSequence = 0;
6
+
7
+ const LEGACY_TYPE_MAP = {
8
+ BAN: 'TEMPORARY_BAN_HIT',
9
+ RATE_LIMIT: 'RATE_LIMIT_EXCEEDED',
10
+ STORE_FAILURE: 'STORE_ERROR',
11
+ THREAT: null,
12
+ BRUTE_FORCE_BLOCK: 'BRUTE_FORCE_BLOCKED',
13
+ };
14
+
15
+ const DETECTOR_TYPE_MAP = {
16
+ SQL_INJECTION: 'SQL_INJECTION_BLOCKED',
17
+ XSS: 'XSS_BLOCKED',
18
+ NOSQL_INJECTION: 'NOSQL_INJECTION_BLOCKED',
19
+ HTTP_PARAMETER_POLLUTION: 'HPP_BLOCKED',
20
+ PROTOTYPE_POLLUTION: 'PROTOTYPE_POLLUTION_BLOCKED',
21
+ PATH_TRAVERSAL: 'PATH_TRAVERSAL_BLOCKED',
22
+ REQUEST_SHAPE: 'REQUEST_SHAPE_BLOCKED',
23
+ };
24
+
25
+ const DETECTOR_NAME_MAP = {
26
+ SQL_INJECTION: 'sql',
27
+ XSS: 'xss',
28
+ NOSQL_INJECTION: 'nosql',
29
+ HTTP_PARAMETER_POLLUTION: 'hpp',
30
+ PROTOTYPE_POLLUTION: 'prototype-pollution',
31
+ PATH_TRAVERSAL: 'path-traversal',
32
+ REQUEST_SHAPE: 'request-shape',
33
+ RATE_LIMIT: 'rate-limit',
34
+ ROUTE_RATE_LIMIT: 'rate-limit',
35
+ BRUTE_FORCE: 'brute-force',
36
+ };
37
+
38
+ const DETECTOR_TYPE_BY_SLUG = Object.entries(DETECTOR_NAME_MAP).reduce((map, [type, slug]) => {
39
+ if (!map[slug]) map[slug] = type;
40
+ return map;
41
+ }, {});
42
+
43
+ function createThreatEvent(input = {}) {
44
+ const source = sanitizeEvent(input);
45
+ const firstThreat = Array.isArray(source.threats) ? source.threats[0] : null;
46
+ const sourceDetector = source.detector || firstThreat?.detector;
47
+ const detectorSlug = normalizeDetector(sourceDetector);
48
+ const detectorType = normalizeDetectorType(source.detectorType || sourceDetector);
49
+ const type = normalizeType(source.type, detectorType || sourceDetector);
50
+ const event = {
51
+ ...source,
52
+ id: source.id || createEventId(),
53
+ type,
54
+ module: source.module || moduleForType(type),
55
+ detector: detectorSlug,
56
+ detectorType,
57
+ detectorSlug,
58
+ severity: source.severity || firstThreat?.severity || severityForType(type),
59
+ action: source.action || actionForType(type),
60
+ reason: source.reason || firstThreat?.reason || reasonForType(type),
61
+ ip: source.ip,
62
+ method: source.method,
63
+ path: source.path || normalizePath(source.url),
64
+ statusCode: source.statusCode,
65
+ policyName: source.policyName,
66
+ keyTypes: source.keyTypes,
67
+ requestId: source.requestId,
68
+ userAgent: sanitizeUserAgent(source.userAgent),
69
+ timestamp: source.timestamp || new Date().toISOString(),
70
+ metadata: sanitizeEvent(source.metadata || {}),
71
+ };
72
+
73
+ if (source.url && !event.url) event.url = source.url;
74
+ if (source.threats) event.threats = source.threats;
75
+ return sanitizeEvent(event);
76
+ }
77
+
78
+ function createStoreErrorEvent(error, context = {}) {
79
+ return createThreatEvent({
80
+ type: 'STORE_ERROR',
81
+ module: context.module || 'store',
82
+ severity: 'medium',
83
+ action: 'error',
84
+ reason: error && error.message ? error.message : String(error),
85
+ ...context,
86
+ });
87
+ }
88
+
89
+ function createHookErrorEvent(error, event = {}) {
90
+ return createThreatEvent({
91
+ type: 'HOOK_ERROR',
92
+ module: 'hook',
93
+ severity: 'low',
94
+ action: 'error',
95
+ reason: error && error.message ? error.message : String(error),
96
+ ip: event.ip,
97
+ method: event.method,
98
+ path: event.path,
99
+ requestId: event.requestId,
100
+ metadata: { sourceEventId: event.id, sourceType: event.type },
101
+ });
102
+ }
103
+
104
+ function createEventId() {
105
+ eventSequence += 1;
106
+ return `evt_${Date.now().toString(36)}_${eventSequence.toString(36)}`;
107
+ }
108
+
109
+ function normalizeType(type, detector) {
110
+ if (LEGACY_TYPE_MAP[type]) return LEGACY_TYPE_MAP[type];
111
+ if (type === 'THREAT' && detector) return DETECTOR_TYPE_MAP[detector] || 'THREAT_BLOCKED';
112
+ if (DETECTOR_TYPE_MAP[detector] && (!type || type === detector))
113
+ return DETECTOR_TYPE_MAP[detector];
114
+ return type || 'SECURITY_EVENT';
115
+ }
116
+
117
+ function normalizeDetector(detector) {
118
+ return DETECTOR_NAME_MAP[detector] || detector || undefined;
119
+ }
120
+
121
+ function normalizeDetectorType(detector) {
122
+ if (!detector) return undefined;
123
+ if (DETECTOR_NAME_MAP[detector]) return detector;
124
+ return DETECTOR_TYPE_BY_SLUG[detector] || detector;
125
+ }
126
+
127
+ function moduleForType(type) {
128
+ if (type.startsWith('BRUTE_FORCE')) return 'brute-force';
129
+ if (type === 'ROUTE_RATE_LIMIT_EXCEEDED') return 'route-policy';
130
+ if (type === 'RATE_LIMIT_EXCEEDED' || type.startsWith('TEMPORARY_BAN')) return 'rate-limit';
131
+ if (type === 'STORE_ERROR') return 'store';
132
+ if (type === 'HOOK_ERROR') return 'hook';
133
+ return 'detector';
134
+ }
135
+
136
+ function severityForType(type) {
137
+ if (type.includes('SQL') || type.includes('NOSQL') || type.includes('BRUTE_FORCE_BLOCK'))
138
+ return 'high';
139
+ if (type.includes('BLOCKED') || type.includes('RATE_LIMIT') || type.includes('STORE'))
140
+ return 'medium';
141
+ if (type.includes('RESET')) return 'low';
142
+ return 'medium';
143
+ }
144
+
145
+ function actionForType(type) {
146
+ if (type.includes('BLOCKED') || type.includes('EXCEEDED') || type.includes('BAN_HIT'))
147
+ return 'blocked';
148
+ if (type.includes('ATTEMPT')) return 'observed';
149
+ if (type.includes('RESET')) return 'reset';
150
+ if (type.includes('ERROR')) return 'error';
151
+ if (type.includes('CREATED')) return 'created';
152
+ return 'observed';
153
+ }
154
+
155
+ function reasonForType(type) {
156
+ return type.toLowerCase().replace(/_/g, ' ');
157
+ }
158
+
159
+ function normalizePath(url) {
160
+ if (!url) return undefined;
161
+ const value = String(url);
162
+ const index = value.indexOf('?');
163
+ return index === -1 ? value : value.slice(0, index);
164
+ }
165
+
166
+ module.exports = {
167
+ createThreatEvent,
168
+ createStoreErrorEvent,
169
+ createHookErrorEvent,
170
+ createEventId,
171
+ normalizeType,
172
+ normalizeDetector,
173
+ normalizeDetectorType,
174
+ };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ const ipaddr = require('ipaddr.js');
4
+
5
+ function getClientIp(req, options = {}) {
6
+ const headers = req.headers || {};
7
+ const directIp = getDirectIp(req);
8
+
9
+ if (!options.trustProxyHeaders) return directIp;
10
+
11
+ if (!isTrustedProxy(directIp, options.trustedProxies || [])) return directIp;
12
+
13
+ const forwarded = getHeader(headers, 'x-forwarded-for');
14
+ const firstForwarded = forwarded ? forwarded.split(',')[0].trim() : '';
15
+ const realIp = getHeader(headers, 'x-real-ip');
16
+ return normalizeIp(firstForwarded || realIp || directIp);
17
+ }
18
+
19
+ function resolveClientIP(req, options = {}) {
20
+ return getClientIp(req, options);
21
+ }
22
+
23
+ function getDirectIp(req) {
24
+ return normalizeIp(req?.socket?.remoteAddress || req?.connection?.remoteAddress || req?.ip);
25
+ }
26
+
27
+ function isTrustedProxy(ip, trustedProxies) {
28
+ return isIpAllowed(ip, trustedProxies);
29
+ }
30
+
31
+ function isIpAllowed(clientIp, allowedIps = []) {
32
+ const parsedClientIp = parseIp(clientIp);
33
+ if (!parsedClientIp || !Array.isArray(allowedIps) || allowedIps.length === 0) return false;
34
+
35
+ return allowedIps.some((entry) => {
36
+ const rule = String(entry || '').trim();
37
+ if (!rule) return false;
38
+
39
+ try {
40
+ if (rule.includes('/')) {
41
+ const [range, bits] = ipaddr.parseCIDR(rule);
42
+ return parsedClientIp.match(range, bits);
43
+ }
44
+
45
+ const parsedRule = parseIp(rule);
46
+ return parsedRule ? parsedClientIp.toString() === parsedRule.toString() : false;
47
+ } catch (_error) {
48
+ return false;
49
+ }
50
+ });
51
+ }
52
+
53
+ function getHeader(headers, name) {
54
+ const lower = name.toLowerCase();
55
+ return (
56
+ headers[lower] ||
57
+ headers[name] ||
58
+ headers[Object.keys(headers).find((key) => key.toLowerCase() === lower)]
59
+ );
60
+ }
61
+
62
+ function normalizeIp(value) {
63
+ const parsed = parseIp(value);
64
+ return parsed ? parsed.toString() : 'unknown';
65
+ }
66
+
67
+ function parseIp(value) {
68
+ const cleaned = cleanIpInput(value);
69
+ if (!cleaned) return null;
70
+
71
+ try {
72
+ return ipaddr.process(cleaned);
73
+ } catch (_error) {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function cleanIpInput(value) {
79
+ let ip = String(value || '').trim();
80
+ if (!ip || ip === 'unknown') return '';
81
+
82
+ if (ip.startsWith('[')) {
83
+ const end = ip.indexOf(']');
84
+ if (end !== -1) ip = ip.slice(1, end);
85
+ }
86
+
87
+ const zoneIndex = ip.indexOf('%');
88
+ if (zoneIndex !== -1) ip = ip.slice(0, zoneIndex);
89
+
90
+ if (ip.startsWith('::ffff:')) return ip;
91
+
92
+ const colonCount = (ip.match(/:/g) || []).length;
93
+ if (colonCount === 1 && ip.includes('.')) {
94
+ const [host] = ip.split(':');
95
+ return host;
96
+ }
97
+
98
+ return ip;
99
+ }
100
+
101
+ module.exports = {
102
+ getClientIp,
103
+ resolveClientIP,
104
+ getDirectIp,
105
+ isTrustedProxy,
106
+ isIpAllowed,
107
+ normalizeIp,
108
+ getHeader,
109
+ };