@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,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SQLInjectionDetector,
|
|
5
|
+
XSSDetector,
|
|
6
|
+
NoSQLDetector,
|
|
7
|
+
HPPDetector,
|
|
8
|
+
PrototypePollutionDetector,
|
|
9
|
+
PathTraversalDetector,
|
|
10
|
+
RequestShapeGuard,
|
|
11
|
+
} = require('../detectors');
|
|
12
|
+
const { safeStringify } = require('../utils/normalize');
|
|
13
|
+
const { severityForThreats } = require('./scoring');
|
|
14
|
+
const {
|
|
15
|
+
createThreatEvent,
|
|
16
|
+
createBanEvent,
|
|
17
|
+
createRateLimitEvent,
|
|
18
|
+
createStoreFailureEvent,
|
|
19
|
+
} = require('./threat-event');
|
|
20
|
+
|
|
21
|
+
async function analyzeRequest(requestData, context) {
|
|
22
|
+
const { config, rateLimiter, logger } = context;
|
|
23
|
+
const timestamp = requestData.timestamp || new Date().toISOString();
|
|
24
|
+
const rateLimit = await checkRateLimit(requestData, { config, rateLimiter, logger, timestamp });
|
|
25
|
+
|
|
26
|
+
if (rateLimit?.storeFailure && rateLimit.failClosed) {
|
|
27
|
+
return {
|
|
28
|
+
allowed: false,
|
|
29
|
+
blocked: true,
|
|
30
|
+
reason: 'STORE_FAILURE',
|
|
31
|
+
statusCode: 503,
|
|
32
|
+
message: 'Rate limit store unavailable.',
|
|
33
|
+
severity: 'medium',
|
|
34
|
+
detector: null,
|
|
35
|
+
threats: [],
|
|
36
|
+
event: rateLimit.event,
|
|
37
|
+
rateLimit: null,
|
|
38
|
+
responseExtra: {},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (rateLimit?.banned) {
|
|
43
|
+
return {
|
|
44
|
+
allowed: false,
|
|
45
|
+
blocked: true,
|
|
46
|
+
reason: 'BAN',
|
|
47
|
+
statusCode: 429,
|
|
48
|
+
message: 'Too many suspicious requests. IP temporarily banned.',
|
|
49
|
+
severity: 'high',
|
|
50
|
+
detector: null,
|
|
51
|
+
threats: [],
|
|
52
|
+
event: createBanEvent(createRequestEventContext(requestData, timestamp)),
|
|
53
|
+
rateLimit,
|
|
54
|
+
responseExtra: { banExpiresAt: rateLimit.banExpiresAt },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (rateLimit?.limited) {
|
|
59
|
+
return {
|
|
60
|
+
allowed: false,
|
|
61
|
+
blocked: true,
|
|
62
|
+
reason: 'RATE_LIMIT',
|
|
63
|
+
statusCode: 429,
|
|
64
|
+
message: 'Request limit reached. Please try again shortly.',
|
|
65
|
+
severity: 'medium',
|
|
66
|
+
detector: null,
|
|
67
|
+
threats: [],
|
|
68
|
+
event: createRateLimitEvent(createRequestEventContext(requestData, timestamp)),
|
|
69
|
+
rateLimit,
|
|
70
|
+
responseExtra: {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const threats = [
|
|
75
|
+
...scanApplicationLayerGuards(requestData, config),
|
|
76
|
+
...scanTargets(requestData.targets || [], config),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
if (threats.length > 0) {
|
|
80
|
+
if (config.rateLimit && rateLimiter) {
|
|
81
|
+
await recordSuspicious(requestData, { config, rateLimiter, logger, timestamp });
|
|
82
|
+
}
|
|
83
|
+
const normalizedThreats = enrichThreats(threats);
|
|
84
|
+
|
|
85
|
+
const event = createThreatEvent({
|
|
86
|
+
ip: requestData.ip,
|
|
87
|
+
timestamp,
|
|
88
|
+
method: requestData.method,
|
|
89
|
+
url: requestData.url,
|
|
90
|
+
path: requestData.path,
|
|
91
|
+
requestId: requestData.requestId,
|
|
92
|
+
userAgent: requestData.userAgent,
|
|
93
|
+
threats: normalizedThreats,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
allowed: false,
|
|
98
|
+
blocked: true,
|
|
99
|
+
reason: 'THREAT',
|
|
100
|
+
statusCode: 400,
|
|
101
|
+
message: 'Request blocked: malicious pattern detected.',
|
|
102
|
+
severity: severityForThreats(normalizedThreats),
|
|
103
|
+
detector: normalizedThreats[0].detector,
|
|
104
|
+
threats: normalizedThreats,
|
|
105
|
+
event,
|
|
106
|
+
rateLimit,
|
|
107
|
+
responseExtra: {
|
|
108
|
+
threats: normalizedThreats.map(toResponseThreat),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
allowed: true,
|
|
115
|
+
blocked: false,
|
|
116
|
+
reason: null,
|
|
117
|
+
statusCode: null,
|
|
118
|
+
message: null,
|
|
119
|
+
severity: 'none',
|
|
120
|
+
detector: null,
|
|
121
|
+
threats: [],
|
|
122
|
+
event: null,
|
|
123
|
+
rateLimit,
|
|
124
|
+
responseExtra: {},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function checkRateLimit(requestData, context) {
|
|
129
|
+
const { config, rateLimiter, logger, timestamp } = context;
|
|
130
|
+
if (!config.rateLimit || !rateLimiter) return null;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
return await rateLimiter.check(requestData.ip);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const mode = config.storeFailureMode === 'fail-closed' ? 'fail-closed' : 'fail-open';
|
|
136
|
+
const event = createStoreFailureEvent({
|
|
137
|
+
...createRequestEventContext(requestData, timestamp),
|
|
138
|
+
error,
|
|
139
|
+
mode,
|
|
140
|
+
module: 'rate-limit',
|
|
141
|
+
});
|
|
142
|
+
if (logger && typeof logger.logStoreError === 'function') logger.logStoreError(error, event);
|
|
143
|
+
|
|
144
|
+
if (mode === 'fail-open') return null;
|
|
145
|
+
|
|
146
|
+
return { storeFailure: true, failClosed: true, event };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function recordSuspicious(requestData, context) {
|
|
151
|
+
const { config, rateLimiter, logger, timestamp } = context;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const result = await rateLimiter.recordSuspicious(requestData.ip);
|
|
155
|
+
if (result?.banned && logger && typeof logger.log === 'function') {
|
|
156
|
+
logger.log({
|
|
157
|
+
type: 'TEMPORARY_BAN_CREATED',
|
|
158
|
+
module: 'rate-limit',
|
|
159
|
+
severity: 'high',
|
|
160
|
+
action: 'created',
|
|
161
|
+
reason: 'Suspicious activity threshold reached',
|
|
162
|
+
...createRequestEventContext(requestData, timestamp),
|
|
163
|
+
metadata: {
|
|
164
|
+
banExpiresAt: result.banExpiresAt,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const mode = config.storeFailureMode === 'fail-closed' ? 'fail-closed' : 'fail-open';
|
|
170
|
+
const event = createStoreFailureEvent({
|
|
171
|
+
...createRequestEventContext(requestData, timestamp),
|
|
172
|
+
error,
|
|
173
|
+
mode,
|
|
174
|
+
module: 'rate-limit',
|
|
175
|
+
});
|
|
176
|
+
if (logger && typeof logger.logStoreError === 'function') logger.logStoreError(error, event);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function createRequestEventContext(requestData, timestamp) {
|
|
181
|
+
return {
|
|
182
|
+
ip: requestData.ip,
|
|
183
|
+
timestamp,
|
|
184
|
+
method: requestData.method,
|
|
185
|
+
path: requestData.path,
|
|
186
|
+
requestId: requestData.requestId,
|
|
187
|
+
userAgent: requestData.userAgent,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function scanApplicationLayerGuards(requestData, config) {
|
|
192
|
+
const threats = [];
|
|
193
|
+
const surfaces = {
|
|
194
|
+
query: requestData.query || {},
|
|
195
|
+
params: requestData.params || {},
|
|
196
|
+
body: requestData.body,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (config.requestShape?.enabled) {
|
|
200
|
+
const hit = RequestShapeGuard.scan(surfaces, config.requestShape);
|
|
201
|
+
if (hit) return [hit];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (config.hpp?.enabled) {
|
|
205
|
+
const hit = HPPDetector.scan(surfaces.query, config.hpp);
|
|
206
|
+
if (hit) threats.push(hit);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (config.prototypePollution?.enabled) {
|
|
210
|
+
const hit = PrototypePollutionDetector.scan(surfaces);
|
|
211
|
+
if (hit) threats.push(hit);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (config.pathTraversal?.enabled) {
|
|
215
|
+
const hit = PathTraversalDetector.scan(requestData.targets || []);
|
|
216
|
+
if (hit) threats.push(hit);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return threats;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function scanTargets(targets, config) {
|
|
223
|
+
const threats = [];
|
|
224
|
+
|
|
225
|
+
for (const { label, value, stringValue } of targets) {
|
|
226
|
+
const str = stringValue != null ? stringValue : safeStringify(value);
|
|
227
|
+
|
|
228
|
+
if (config.sql) {
|
|
229
|
+
const hit = SQLInjectionDetector.scan(str);
|
|
230
|
+
if (hit) threats.push({ detector: 'SQL_INJECTION', field: label, pattern: hit });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (config.xss) {
|
|
234
|
+
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 });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return threats;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function enrichThreats(threats) {
|
|
248
|
+
return threats.map((threat) => ({
|
|
249
|
+
...threat,
|
|
250
|
+
severity: threat.severity || severityForThreats([threat]),
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function toResponseThreat(threat) {
|
|
255
|
+
const responseThreat = {
|
|
256
|
+
detector: threat.detector,
|
|
257
|
+
field: threat.field,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
if (threat.reason) responseThreat.reason = threat.reason;
|
|
261
|
+
return responseThreat;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { analyzeRequest, scanTargets, scanApplicationLayerGuards };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function createRateLimitResult({ limited, banned, remaining, resetAt, banExpiresAt }) {
|
|
4
|
+
return {
|
|
5
|
+
limited: Boolean(limited),
|
|
6
|
+
banned: Boolean(banned),
|
|
7
|
+
remaining: Math.max(0, Number(remaining || 0)),
|
|
8
|
+
resetAt: resetAt || null,
|
|
9
|
+
banExpiresAt: banExpiresAt || null,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { createRateLimitResult };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const THREAT_SEVERITY = {
|
|
4
|
+
SQL_INJECTION: 'high',
|
|
5
|
+
XSS: 'high',
|
|
6
|
+
NOSQL_INJECTION: 'high',
|
|
7
|
+
HTTP_PARAMETER_POLLUTION: 'medium',
|
|
8
|
+
PROTOTYPE_POLLUTION: 'high',
|
|
9
|
+
PATH_TRAVERSAL: 'high',
|
|
10
|
+
REQUEST_SHAPE: 'medium',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function severityForThreats(threats) {
|
|
14
|
+
if (!threats || threats.length === 0) return 'none';
|
|
15
|
+
return THREAT_SEVERITY[threats[0].detector] || 'medium';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { severityForThreats };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function createThreatEvent({ ip, timestamp, method, url, path, threats, requestId, userAgent }) {
|
|
4
|
+
const firstThreat = threats?.[0] || {};
|
|
5
|
+
return {
|
|
6
|
+
type: 'THREAT',
|
|
7
|
+
detector: firstThreat.detector,
|
|
8
|
+
severity: firstThreat.severity,
|
|
9
|
+
reason: firstThreat.reason,
|
|
10
|
+
target: firstThreat.field,
|
|
11
|
+
ip,
|
|
12
|
+
timestamp,
|
|
13
|
+
method,
|
|
14
|
+
url,
|
|
15
|
+
path,
|
|
16
|
+
requestId,
|
|
17
|
+
userAgent,
|
|
18
|
+
threats,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createBanEvent({ ip, timestamp, method, path, requestId, userAgent }) {
|
|
23
|
+
return {
|
|
24
|
+
type: 'BAN',
|
|
25
|
+
ip,
|
|
26
|
+
reason: 'Ban for suspicious activity',
|
|
27
|
+
timestamp,
|
|
28
|
+
method,
|
|
29
|
+
path,
|
|
30
|
+
requestId,
|
|
31
|
+
userAgent,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createRateLimitEvent({ ip, timestamp, method, path, requestId, userAgent }) {
|
|
36
|
+
return { type: 'RATE_LIMIT', ip, timestamp, method, path, requestId, userAgent };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createStoreFailureEvent({
|
|
40
|
+
ip,
|
|
41
|
+
timestamp,
|
|
42
|
+
error,
|
|
43
|
+
mode,
|
|
44
|
+
method,
|
|
45
|
+
path,
|
|
46
|
+
requestId,
|
|
47
|
+
userAgent,
|
|
48
|
+
module,
|
|
49
|
+
}) {
|
|
50
|
+
return {
|
|
51
|
+
type: 'STORE_FAILURE',
|
|
52
|
+
module,
|
|
53
|
+
ip,
|
|
54
|
+
timestamp,
|
|
55
|
+
reason: error && error.message ? error.message : String(error),
|
|
56
|
+
mode,
|
|
57
|
+
method,
|
|
58
|
+
path,
|
|
59
|
+
requestId,
|
|
60
|
+
userAgent,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
createThreatEvent,
|
|
66
|
+
createBanEvent,
|
|
67
|
+
createRateLimitEvent,
|
|
68
|
+
createStoreFailureEvent,
|
|
69
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const HPPDetector = {
|
|
4
|
+
scan(query, options = {}) {
|
|
5
|
+
if (!query || typeof query !== 'object') return null;
|
|
6
|
+
|
|
7
|
+
const allowed = new Set(options.allowDuplicateParamsFor || []);
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
for (const [key, value] of Object.entries(query)) {
|
|
11
|
+
if (allowed.has(key)) continue;
|
|
12
|
+
|
|
13
|
+
if (Array.isArray(value) && value.length > 1) {
|
|
14
|
+
return {
|
|
15
|
+
detector: 'HTTP_PARAMETER_POLLUTION',
|
|
16
|
+
field: `query.${key}`,
|
|
17
|
+
pattern: 'duplicate-query-param',
|
|
18
|
+
reason: `Duplicate query parameter: ${key}`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
} catch (_) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
module.exports = { HPPDetector };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SQLInjectionDetector } = require('./sql');
|
|
4
|
+
const { XSSDetector } = require('./xss');
|
|
5
|
+
const { NoSQLDetector } = require('./nosql');
|
|
6
|
+
const { HPPDetector } = require('./hpp');
|
|
7
|
+
const { PrototypePollutionDetector } = require('./prototype-pollution');
|
|
8
|
+
const { PathTraversalDetector } = require('./path-traversal');
|
|
9
|
+
const { RequestShapeGuard } = require('./request-shape');
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
SQLInjectionDetector,
|
|
13
|
+
XSSDetector,
|
|
14
|
+
NoSQLDetector,
|
|
15
|
+
HPPDetector,
|
|
16
|
+
PrototypePollutionDetector,
|
|
17
|
+
PathTraversalDetector,
|
|
18
|
+
RequestShapeGuard,
|
|
19
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
NOSQL_DANGEROUS_OPERATORS,
|
|
5
|
+
NOSQL_SUSPICIOUS_OPERATORS,
|
|
6
|
+
NOSQL_STRING_PATTERNS,
|
|
7
|
+
} = require('../../constants/patterns');
|
|
8
|
+
|
|
9
|
+
const NoSQLDetector = {
|
|
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);
|
|
14
|
+
return null;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function _scanObject(obj, depth = 0) {
|
|
19
|
+
if (depth > 6) return null;
|
|
20
|
+
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}`;
|
|
23
|
+
const val = obj[key];
|
|
24
|
+
if (val && typeof val === 'object') {
|
|
25
|
+
const nested = _scanObject(val, depth + 1);
|
|
26
|
+
if (nested) return nested;
|
|
27
|
+
}
|
|
28
|
+
if (typeof val === 'string') {
|
|
29
|
+
const hit = _scanString(val);
|
|
30
|
+
if (hit) return hit;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _scanString(value) {
|
|
37
|
+
if (!value || value.trim() === '') return null;
|
|
38
|
+
if (value.trim().startsWith('{') || value.trim().startsWith('[')) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(value);
|
|
41
|
+
if (parsed && typeof parsed === 'object') {
|
|
42
|
+
const hit = _scanObject(parsed);
|
|
43
|
+
if (hit) return hit;
|
|
44
|
+
}
|
|
45
|
+
} catch (_) {}
|
|
46
|
+
}
|
|
47
|
+
for (const pattern of NOSQL_STRING_PATTERNS) {
|
|
48
|
+
if (pattern.test(value)) return pattern.toString();
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { NoSQLDetector };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { decodeUrlValue } = require('../utils/decode');
|
|
4
|
+
|
|
5
|
+
const TRAVERSAL_PATTERN = /(^|[\\/])\.\.([\\/]|$)/;
|
|
6
|
+
|
|
7
|
+
const PathTraversalDetector = {
|
|
8
|
+
scan(targets) {
|
|
9
|
+
for (const target of targets || []) {
|
|
10
|
+
const hit = scanTarget(target);
|
|
11
|
+
if (hit) return hit;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function scanTarget(target) {
|
|
18
|
+
if (!target || !isRequestValueTarget(target.label)) return null;
|
|
19
|
+
|
|
20
|
+
const strings = collectStrings(target.value, target.label);
|
|
21
|
+
for (const item of strings) {
|
|
22
|
+
const normalized = normalizePathCandidate(item.value);
|
|
23
|
+
if (TRAVERSAL_PATTERN.test(normalized)) {
|
|
24
|
+
return {
|
|
25
|
+
detector: 'PATH_TRAVERSAL',
|
|
26
|
+
field: item.label,
|
|
27
|
+
pattern: 'path-traversal-segment',
|
|
28
|
+
reason: 'Path traversal sequence detected',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isRequestValueTarget(label) {
|
|
37
|
+
return (
|
|
38
|
+
label === 'body' ||
|
|
39
|
+
label.startsWith('body.') ||
|
|
40
|
+
label.startsWith('query.') ||
|
|
41
|
+
label.startsWith('params.')
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function collectStrings(value, label, seen = new WeakSet()) {
|
|
46
|
+
if (typeof value === 'string') return [{ label, value }];
|
|
47
|
+
if (!value || typeof value !== 'object') return [];
|
|
48
|
+
if (seen.has(value)) return [];
|
|
49
|
+
seen.add(value);
|
|
50
|
+
|
|
51
|
+
const strings = [];
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
for (let i = 0; i < value.length; i++) {
|
|
54
|
+
strings.push(...collectStrings(value[i], `${label}[${i}]`, seen));
|
|
55
|
+
}
|
|
56
|
+
return strings;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
for (const [key, child] of Object.entries(value)) {
|
|
61
|
+
strings.push(...collectStrings(child, `${label}.${key}`, seen));
|
|
62
|
+
}
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
|
|
65
|
+
return strings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizePathCandidate(value) {
|
|
69
|
+
return decodeUrlValue(value, 2).replace(/\\/g, '/');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { PathTraversalDetector };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { decodeUrlValue } = require('../utils/decode');
|
|
4
|
+
|
|
5
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
6
|
+
|
|
7
|
+
const PrototypePollutionDetector = {
|
|
8
|
+
scan(surfaces) {
|
|
9
|
+
const seen = new WeakSet();
|
|
10
|
+
|
|
11
|
+
for (const [surface, value] of Object.entries(surfaces || {})) {
|
|
12
|
+
const hit = scanValue(value, surface, seen);
|
|
13
|
+
if (hit) return hit;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return null;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function scanValue(value, path, seen) {
|
|
21
|
+
if (!value || typeof value !== 'object') return null;
|
|
22
|
+
if (seen.has(value)) return null;
|
|
23
|
+
seen.add(value);
|
|
24
|
+
|
|
25
|
+
let keys;
|
|
26
|
+
try {
|
|
27
|
+
keys = Reflect.ownKeys(value);
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
if (typeof key !== 'string') continue;
|
|
34
|
+
|
|
35
|
+
const childPath = Array.isArray(value) ? `${path}[${key}]` : `${path}.${key}`;
|
|
36
|
+
const dangerousKey = findDangerousKey(key);
|
|
37
|
+
if (dangerousKey) {
|
|
38
|
+
return {
|
|
39
|
+
detector: 'PROTOTYPE_POLLUTION',
|
|
40
|
+
field: childPath,
|
|
41
|
+
pattern: dangerousKey,
|
|
42
|
+
reason: `Dangerous object key: ${dangerousKey}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const nested = scanValue(value[key], childPath, seen);
|
|
47
|
+
if (nested) return nested;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findDangerousKey(key) {
|
|
54
|
+
const candidates = new Set([key]);
|
|
55
|
+
const decoded = decodeUrlValue(key, 2);
|
|
56
|
+
candidates.add(decoded);
|
|
57
|
+
|
|
58
|
+
for (const candidate of [...candidates]) {
|
|
59
|
+
for (const part of String(candidate).split('.')) candidates.add(part);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (DANGEROUS_KEYS.has(candidate)) return candidate;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { PrototypePollutionDetector };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const RequestShapeGuard = {
|
|
4
|
+
scan(surfaces, options) {
|
|
5
|
+
const limits = {
|
|
6
|
+
maxDepth: options.maxDepth,
|
|
7
|
+
maxKeys: options.maxKeys,
|
|
8
|
+
maxArrayLength: options.maxArrayLength,
|
|
9
|
+
maxStringLength: options.maxStringLength,
|
|
10
|
+
};
|
|
11
|
+
const state = { keyCount: 0, seen: new WeakSet() };
|
|
12
|
+
|
|
13
|
+
for (const [surface, value] of Object.entries(surfaces || {})) {
|
|
14
|
+
const hit = scanValue(value, surface, 0, limits, state);
|
|
15
|
+
if (hit) return hit;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return null;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function scanValue(value, path, depth, limits, state) {
|
|
23
|
+
if (typeof value === 'string' && value.length > limits.maxStringLength) {
|
|
24
|
+
return shapeThreat(path, 'maxStringLength', `String length exceeds ${limits.maxStringLength}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!value || typeof value !== 'object') return null;
|
|
28
|
+
if (state.seen.has(value)) return null;
|
|
29
|
+
state.seen.add(value);
|
|
30
|
+
|
|
31
|
+
if (depth > limits.maxDepth) {
|
|
32
|
+
return shapeThreat(path, 'maxDepth', `Object depth exceeds ${limits.maxDepth}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
if (value.length > limits.maxArrayLength) {
|
|
37
|
+
return shapeThreat(path, 'maxArrayLength', `Array length exceeds ${limits.maxArrayLength}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < value.length; i++) {
|
|
41
|
+
const hit = scanValue(value[i], `${path}[${i}]`, depth + 1, limits, state);
|
|
42
|
+
if (hit) return hit;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = Object.entries(value);
|
|
50
|
+
} catch (_) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
state.keyCount += entries.length;
|
|
55
|
+
if (state.keyCount > limits.maxKeys) {
|
|
56
|
+
return shapeThreat(path, 'maxKeys', `Object key count exceeds ${limits.maxKeys}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const [key, child] of entries) {
|
|
60
|
+
const hit = scanValue(child, `${path}.${key}`, depth + 1, limits, state);
|
|
61
|
+
if (hit) return hit;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function shapeThreat(field, pattern, reason) {
|
|
68
|
+
return {
|
|
69
|
+
detector: 'REQUEST_SHAPE',
|
|
70
|
+
field,
|
|
71
|
+
pattern,
|
|
72
|
+
reason,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { RequestShapeGuard };
|