itworksbut 0.7.2 → 0.7.3
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/package.json +1 -1
- package/src/stress/discoverEndpoints.js +214 -28
- package/src/utils/targetSafety.js +67 -68
package/package.json
CHANGED
|
@@ -7,6 +7,16 @@ import { isServerOrApiFile } from "../checks/helpers.js";
|
|
|
7
7
|
const HTTP_METHODS = ["get", "head", "post", "put", "patch", "delete", "options"];
|
|
8
8
|
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
9
9
|
const ROUTE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]);
|
|
10
|
+
const API_HANDLER_EVIDENCE = [
|
|
11
|
+
/\b(?:req|request)\s*,\s*(?:res|response)\b/i,
|
|
12
|
+
/\b(?:res|response)\s*\.\s*(?:json|send|status|end|redirect|writeHead|setHeader)\s*\(/i,
|
|
13
|
+
/\b(?:ctx|context)\s*\.\s*(?:body|response|status|json)\b/i,
|
|
14
|
+
/\bNextResponse\s*\.\s*(?:json|redirect)\s*\(/,
|
|
15
|
+
/\b(?:new\s+Response|Response\.json)\s*\(/,
|
|
16
|
+
/\bdefineEventHandler\s*\(/,
|
|
17
|
+
/\bexport\s+(?:async\s+)?function\s+(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\b/,
|
|
18
|
+
/\bexport\s+(?:const|let|var)\s+(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\s*=/
|
|
19
|
+
];
|
|
10
20
|
const DISCOVERY_IGNORE = [
|
|
11
21
|
...DEFAULT_IGNORE,
|
|
12
22
|
"test/**",
|
|
@@ -40,7 +50,7 @@ export async function discoverEndpointsFromFiles({ rootPath, files, readFile })
|
|
|
40
50
|
const mountPrefixes = collectMountPrefixes(fileContents);
|
|
41
51
|
|
|
42
52
|
for (const { file, content } of fileContents) {
|
|
43
|
-
endpoints.push(...discoverExpressEndpoints(file, content));
|
|
53
|
+
endpoints.push(...discoverExpressEndpoints(file, content, mountPrefixes));
|
|
44
54
|
endpoints.push(...discoverMountedRouterEndpoints(file, content, mountPrefixes));
|
|
45
55
|
endpoints.push(...discoverFastifyRouteObjects(file, content));
|
|
46
56
|
endpoints.push(...discoverFetchReferences(file, content));
|
|
@@ -89,34 +99,41 @@ export function classifyEndpoints(endpoints) {
|
|
|
89
99
|
};
|
|
90
100
|
}
|
|
91
101
|
|
|
92
|
-
function discoverExpressEndpoints(file, content) {
|
|
102
|
+
function discoverExpressEndpoints(file, content, mountPrefixes = []) {
|
|
93
103
|
const endpoints = [];
|
|
94
104
|
const methods = HTTP_METHODS.join("|");
|
|
95
|
-
const regex = new RegExp(`\\b[A-Za-z_$][\\w$]
|
|
105
|
+
const regex = new RegExp(`\\b([A-Za-z_$][\\w$]*)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\3`, "gi");
|
|
106
|
+
const receivers = collectRouteReceivers(content);
|
|
107
|
+
const mountedRouterFile = hasApplicableMount(file, mountPrefixes);
|
|
96
108
|
let match;
|
|
97
109
|
|
|
98
110
|
while ((match = regex.exec(content)) !== null) {
|
|
99
|
-
|
|
111
|
+
if (!isRouteReceiver(match[1], receivers)) continue;
|
|
112
|
+
if (mountedRouterFile && isRouterReceiver(match[1], receivers)) continue;
|
|
113
|
+
const routePath = normalizeRoutePath(match[4]);
|
|
100
114
|
if (!routePath) continue;
|
|
101
|
-
endpoints.push(endpoint(match[
|
|
115
|
+
endpoints.push(endpoint(match[2], routePath, file, "express"));
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
return endpoints;
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
function discoverMountedRouterEndpoints(file, content, mountPrefixes) {
|
|
108
|
-
|
|
122
|
+
const applicableMounts = mountPrefixes.filter((mount) => mountAppliesToFile(mount, file));
|
|
123
|
+
if (applicableMounts.length === 0) return [];
|
|
109
124
|
|
|
110
125
|
const endpoints = [];
|
|
111
126
|
const methods = HTTP_METHODS.join("|");
|
|
112
|
-
const regex = new RegExp(`\\b(
|
|
127
|
+
const regex = new RegExp(`\\b([A-Za-z_$][\\w$]*)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\3`, "gi");
|
|
128
|
+
const receivers = collectRouteReceivers(content);
|
|
113
129
|
let match;
|
|
114
130
|
|
|
115
131
|
while ((match = regex.exec(content)) !== null) {
|
|
116
|
-
|
|
132
|
+
if (!isRouterReceiver(match[1], receivers)) continue;
|
|
133
|
+
const routePath = normalizeRoutePath(match[4]);
|
|
117
134
|
if (!routePath || routePath.startsWith("/api")) continue;
|
|
118
|
-
for (const
|
|
119
|
-
endpoints.push(endpoint(match[
|
|
135
|
+
for (const mount of applicableMounts) {
|
|
136
|
+
endpoints.push(endpoint(match[2], joinRoutePath(mount.prefix, routePath), file, "mounted-router"));
|
|
120
137
|
}
|
|
121
138
|
}
|
|
122
139
|
|
|
@@ -240,11 +257,16 @@ function discoverApiCandidateFileEndpoints(file, content) {
|
|
|
240
257
|
|
|
241
258
|
function discoverExportedRouteMethods(content) {
|
|
242
259
|
const methods = new Set();
|
|
243
|
-
const
|
|
244
|
-
|
|
260
|
+
const regexes = [
|
|
261
|
+
/\bexport\s+(?:async\s+)?function\s+(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\b/g,
|
|
262
|
+
/\bexport\s+(?:const|let|var)\s+(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\s*=/g
|
|
263
|
+
];
|
|
245
264
|
|
|
246
|
-
|
|
247
|
-
|
|
265
|
+
for (const regex of regexes) {
|
|
266
|
+
let match;
|
|
267
|
+
while ((match = regex.exec(content)) !== null) {
|
|
268
|
+
methods.add(match[1]);
|
|
269
|
+
}
|
|
248
270
|
}
|
|
249
271
|
|
|
250
272
|
return [...methods];
|
|
@@ -263,22 +285,128 @@ function discoverMethodGuards(content) {
|
|
|
263
285
|
}
|
|
264
286
|
|
|
265
287
|
function collectMountPrefixes(fileContents) {
|
|
266
|
-
const
|
|
267
|
-
const useRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*use\s*\(\s*(['"`])([^'"`]+)\1\s
|
|
268
|
-
const registerRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*register\s*\([
|
|
269
|
-
|
|
270
|
-
for (const { content } of fileContents) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
288
|
+
const mounts = [];
|
|
289
|
+
const useRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*use\s*\(\s*(['"`])([^'"`]+)\1\s*,\s*(?:([A-Za-z_$][\w$]*)\s*\(|([A-Za-z_$][\w$]*)\b|require\s*\(\s*(['"`])([^'"`]+)\5\s*\))/gi;
|
|
290
|
+
const registerRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*register\s*\(\s*([A-Za-z_$][\w$]*)\b[\s\S]*?\bprefix\s*:\s*(['"`])([^'"`]+)\2/gi;
|
|
291
|
+
|
|
292
|
+
for (const { file, content } of fileContents) {
|
|
293
|
+
const localModuleBindings = collectLocalModuleBindings(file, content);
|
|
294
|
+
let match;
|
|
295
|
+
|
|
296
|
+
useRegex.lastIndex = 0;
|
|
297
|
+
while ((match = useRegex.exec(content)) !== null) {
|
|
298
|
+
const mount = createUseMount(match, localModuleBindings, file);
|
|
299
|
+
if (mount) mounts.push(mount);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
registerRegex.lastIndex = 0;
|
|
303
|
+
while ((match = registerRegex.exec(content)) !== null) {
|
|
304
|
+
const mount = createNamedMount(match[3], match[1], localModuleBindings);
|
|
305
|
+
if (mount) mounts.push(mount);
|
|
278
306
|
}
|
|
279
307
|
}
|
|
280
308
|
|
|
281
|
-
return
|
|
309
|
+
return dedupeMounts(mounts);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function collectLocalModuleBindings(file, content) {
|
|
313
|
+
const bindings = new Map();
|
|
314
|
+
const destructuredRequireRegex = /\b(?:const|let|var)\s*\{\s*([^}]+)\s*\}\s*=\s*require\s*\(\s*(['"`])([^'"`]+)\2\s*\)/g;
|
|
315
|
+
const requireRegex = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*(['"`])([^'"`]+)\2\s*\)/g;
|
|
316
|
+
const namedImportRegex = /\bimport\s*\{\s*([^}]+)\s*\}\s*from\s*(['"`])([^'"`]+)\2/g;
|
|
317
|
+
const defaultImportRegex = /\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*(['"`])([^'"`]+)\2/g;
|
|
318
|
+
const namespaceImportRegex = /\bimport\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*(['"`])([^'"`]+)\2/g;
|
|
319
|
+
|
|
320
|
+
let match;
|
|
321
|
+
while ((match = destructuredRequireRegex.exec(content)) !== null) {
|
|
322
|
+
addNamedBindings(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
while ((match = requireRegex.exec(content)) !== null) {
|
|
326
|
+
addBinding(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
while ((match = namedImportRegex.exec(content)) !== null) {
|
|
330
|
+
addNamedBindings(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
while ((match = defaultImportRegex.exec(content)) !== null) {
|
|
334
|
+
addBinding(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
while ((match = namespaceImportRegex.exec(content)) !== null) {
|
|
338
|
+
addBinding(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return bindings;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function addNamedBindings(bindings, bindingList, targetFiles) {
|
|
345
|
+
for (const binding of bindingList.split(",")) {
|
|
346
|
+
const normalized = binding.trim();
|
|
347
|
+
if (!normalized) continue;
|
|
348
|
+
const localName = normalized.includes(" as ")
|
|
349
|
+
? normalized.split(/\s+as\s+/).pop().trim()
|
|
350
|
+
: normalized.split(":").pop().trim();
|
|
351
|
+
addBinding(bindings, localName, targetFiles);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function addBinding(bindings, name, targetFiles) {
|
|
356
|
+
if (!name || targetFiles.length === 0) return;
|
|
357
|
+
bindings.set(name, targetFiles);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function createUseMount(match, localModuleBindings, file) {
|
|
361
|
+
const [, , rawPrefix, calledIdentifier, identifier, , requireSpecifier] = match;
|
|
362
|
+
const mountedName = calledIdentifier || identifier;
|
|
363
|
+
if (requireSpecifier) {
|
|
364
|
+
return createMount(rawPrefix, resolveLocalModuleFiles(file, requireSpecifier));
|
|
365
|
+
}
|
|
366
|
+
return createNamedMount(rawPrefix, mountedName, localModuleBindings, Boolean(calledIdentifier));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function createNamedMount(rawPrefix, mountedName, localModuleBindings, called = false) {
|
|
370
|
+
const prefix = normalizeRoutePath(rawPrefix);
|
|
371
|
+
if (!prefix || prefix === "/") return null;
|
|
372
|
+
|
|
373
|
+
const targetFiles = localModuleBindings.get(mountedName) || [];
|
|
374
|
+
if (targetFiles.length > 0) return { prefix, targetFiles };
|
|
375
|
+
if (!called && isRouterReceiver(mountedName)) return { prefix, targetFiles: [] };
|
|
376
|
+
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function createMount(rawPrefix, targetFiles) {
|
|
381
|
+
const prefix = normalizeRoutePath(rawPrefix);
|
|
382
|
+
if (!prefix || prefix === "/" || targetFiles.length === 0) return null;
|
|
383
|
+
return { prefix, targetFiles };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function resolveLocalModuleFiles(file, specifier) {
|
|
387
|
+
if (!specifier || !specifier.startsWith(".")) return [];
|
|
388
|
+
|
|
389
|
+
const dirname = path.posix.dirname(normalizeFilePath(file));
|
|
390
|
+
const base = path.posix.normalize(path.posix.join(dirname, normalizeFilePath(specifier)));
|
|
391
|
+
if (ROUTE_EXTENSIONS.has(path.posix.extname(base))) return [base];
|
|
392
|
+
|
|
393
|
+
return [
|
|
394
|
+
...[...ROUTE_EXTENSIONS].map((extension) => `${base}${extension}`),
|
|
395
|
+
...[...ROUTE_EXTENSIONS].map((extension) => `${base}/index${extension}`)
|
|
396
|
+
];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function dedupeMounts(mounts) {
|
|
400
|
+
const byKey = new Map();
|
|
401
|
+
for (const mount of mounts) {
|
|
402
|
+
const key = `${mount.prefix}|${mount.targetFiles.join(",")}`;
|
|
403
|
+
if (!byKey.has(key)) byKey.set(key, mount);
|
|
404
|
+
}
|
|
405
|
+
return [...byKey.values()].sort((a, b) => {
|
|
406
|
+
const byPrefix = a.prefix.localeCompare(b.prefix);
|
|
407
|
+
if (byPrefix !== 0) return byPrefix;
|
|
408
|
+
return a.targetFiles.join(",").localeCompare(b.targetFiles.join(","));
|
|
409
|
+
});
|
|
282
410
|
}
|
|
283
411
|
|
|
284
412
|
function endpoint(method, routePath, file, type) {
|
|
@@ -304,8 +432,58 @@ function isSourceFile(file) {
|
|
|
304
432
|
return ROUTE_EXTENSIONS.has(path.extname(normalized));
|
|
305
433
|
}
|
|
306
434
|
|
|
435
|
+
function hasApplicableMount(file, mounts) {
|
|
436
|
+
return mounts.some((mount) => mountAppliesToFile(mount, file));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function mountAppliesToFile(mount, file) {
|
|
440
|
+
const normalized = normalizeFilePath(file);
|
|
441
|
+
if (mount.targetFiles.length > 0) return mount.targetFiles.includes(normalized);
|
|
442
|
+
return isLikelyRouterModulePath(normalized);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isLikelyRouterModulePath(file) {
|
|
446
|
+
return (
|
|
447
|
+
file.startsWith("routes/") ||
|
|
448
|
+
file.startsWith("api/") ||
|
|
449
|
+
file.includes("/routes/") ||
|
|
450
|
+
file.includes("/api/")
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function collectRouteReceivers(content) {
|
|
455
|
+
const all = new Set(["app", "server", "router", "apirouter", "routes", "route", "fastify"]);
|
|
456
|
+
const routers = new Set(["router", "apirouter", "routes", "route"]);
|
|
457
|
+
const expressAppRegex = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:express|fastify)\s*\(/g;
|
|
458
|
+
const expressRouterRegex = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:express\s*\.\s*Router|Router)\s*\(/g;
|
|
459
|
+
|
|
460
|
+
let match;
|
|
461
|
+
while ((match = expressAppRegex.exec(content)) !== null) {
|
|
462
|
+
all.add(match[1].toLowerCase());
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
while ((match = expressRouterRegex.exec(content)) !== null) {
|
|
466
|
+
all.add(match[1].toLowerCase());
|
|
467
|
+
routers.add(match[1].toLowerCase());
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { all, routers };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function isRouteReceiver(value, receivers = collectRouteReceivers("")) {
|
|
474
|
+
const normalized = String(value || "").toLowerCase();
|
|
475
|
+
return receivers.all.has(normalized) || normalized.endsWith("router") || normalized.endsWith("server");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function isRouterReceiver(value, receivers = collectRouteReceivers("")) {
|
|
479
|
+
const normalized = String(value || "").toLowerCase();
|
|
480
|
+
return receivers.routers.has(normalized) || normalized.endsWith("router");
|
|
481
|
+
}
|
|
482
|
+
|
|
307
483
|
function isSastApiCandidate(file, content) {
|
|
308
484
|
const normalized = normalizeFilePath(file);
|
|
485
|
+
if (hasVendoredPathSegment(normalized)) return false;
|
|
486
|
+
|
|
309
487
|
return (
|
|
310
488
|
isServerOrApiFile(normalized) ||
|
|
311
489
|
normalized.startsWith("api/") ||
|
|
@@ -314,7 +492,15 @@ function isSastApiCandidate(file, content) {
|
|
|
314
492
|
normalized.startsWith("controllers/") ||
|
|
315
493
|
normalized.includes("/handlers/") ||
|
|
316
494
|
normalized.includes("/controllers/")
|
|
317
|
-
) &&
|
|
495
|
+
) && hasApiHandlerEvidence(content);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function hasApiHandlerEvidence(content) {
|
|
499
|
+
return API_HANDLER_EVIDENCE.some((regex) => regex.test(content));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function hasVendoredPathSegment(file) {
|
|
503
|
+
return /(?:^|\/)(?:vendor|vendors|third_party|third-party)\//i.test(file);
|
|
318
504
|
}
|
|
319
505
|
|
|
320
506
|
function hasExplicitRouteDeclaration(content) {
|
|
@@ -1,100 +1,99 @@
|
|
|
1
|
-
const LOCAL_HOSTS = new Set([
|
|
1
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0']);
|
|
2
2
|
|
|
3
3
|
export const STRESS_DEFAULTS = {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
target: 'http://localhost:3000',
|
|
5
|
+
duration: 30,
|
|
6
|
+
arrivalRate: 50,
|
|
7
|
+
maxVusers: 200,
|
|
8
8
|
};
|
|
9
|
-
|
|
10
9
|
export const STRESS_LIMITS = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
duration: 300,
|
|
11
|
+
arrivalRate: 100,
|
|
12
|
+
maxVusers: 400,
|
|
14
13
|
};
|
|
15
14
|
|
|
16
15
|
export function validateStressOptions(args = {}) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
const target = normalizeTarget(args.target || STRESS_DEFAULTS.target);
|
|
17
|
+
const parsedTarget = new URL(target);
|
|
18
|
+
const targetPath = getExplicitTargetPath(parsedTarget);
|
|
19
|
+
const duration = parsePositiveNumber(args.duration, STRESS_DEFAULTS.duration, 'duration');
|
|
20
|
+
const arrivalRate = parsePositiveNumber(args.arrivalRate, STRESS_DEFAULTS.arrivalRate, 'arrival-rate');
|
|
21
|
+
const maxVusers = parsePositiveInteger(args.maxVusers, STRESS_DEFAULTS.maxVusers, 'max-vusers');
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
assertWithinLimit(duration, STRESS_LIMITS.duration, 'duration', 'seconds');
|
|
24
|
+
assertWithinLimit(arrivalRate, STRESS_LIMITS.arrivalRate, 'arrival-rate', 'requests/second');
|
|
25
|
+
assertWithinLimit(maxVusers, STRESS_LIMITS.maxVusers, 'max-vusers', 'virtual users');
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
const local = isLocalTarget(target);
|
|
28
|
+
if (!local && !args.iOwnThis) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'Refusing to stress-test an external target without --i-own-this. Only run this against systems you own or are explicitly authorized to test.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
return {
|
|
35
|
+
target,
|
|
36
|
+
artilleryTarget: targetPath ? parsedTarget.origin : target,
|
|
37
|
+
targetPath,
|
|
38
|
+
duration,
|
|
39
|
+
arrivalRate,
|
|
40
|
+
maxVusers,
|
|
41
|
+
iOwnThis: Boolean(args.iOwnThis),
|
|
42
|
+
local,
|
|
43
|
+
};
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
export function isLocalTarget(target) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = new URL(target);
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
54
53
|
|
|
55
|
-
|
|
54
|
+
return LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
function normalizeTarget(value) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = new URL(value);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(`Invalid stress target URL: ${value}`);
|
|
63
|
+
}
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
66
|
+
throw new Error('Stress target must use http:// or https://.');
|
|
67
|
+
}
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
return parsed.toString().replace(/\/$/, '');
|
|
71
70
|
}
|
|
72
71
|
|
|
73
72
|
function getExplicitTargetPath(parsed) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
const pathname = parsed.pathname.replace(/\/$/, '') || '/';
|
|
74
|
+
if (pathname === '/' && !parsed.search) return null;
|
|
75
|
+
return `${pathname}${parsed.search}`;
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
function parsePositiveNumber(value, fallback, label) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
80
|
+
const number = Number(value);
|
|
81
|
+
if (!Number.isFinite(number) || number <= 0) {
|
|
82
|
+
throw new Error(`Invalid --${label}: expected a positive number.`);
|
|
83
|
+
}
|
|
84
|
+
return number;
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
function parsePositiveInteger(value, fallback, label) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
const number = parsePositiveNumber(value, fallback, label);
|
|
89
|
+
if (!Number.isInteger(number)) {
|
|
90
|
+
throw new Error(`Invalid --${label}: expected a positive integer.`);
|
|
91
|
+
}
|
|
92
|
+
return number;
|
|
94
93
|
}
|
|
95
94
|
|
|
96
95
|
function assertWithinLimit(value, limit, label, unit) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
if (value > limit) {
|
|
97
|
+
throw new Error(`Refusing --${label} ${value}. Maximum allowed is ${limit} ${unit}.`);
|
|
98
|
+
}
|
|
100
99
|
}
|