itworksbut 0.7.1 → 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/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/terminal.js +83 -69
- package/src/commands/stress.js +17 -2
- package/src/stress/discoverEndpoints.js +388 -20
- package/src/stress/stressRenderer.js +70 -56
- package/src/utils/targetSafety.js +67 -68
package/README.md
CHANGED
|
@@ -270,6 +270,6 @@ Each check is a plain ESM module with an `id`, metadata, and async `run(context)
|
|
|
270
270
|
|
|
271
271
|
## What It Does Not Guarantee
|
|
272
272
|
|
|
273
|
-
ItWorksBut is a static heuristic scanner
|
|
273
|
+
ItWorksBut is a static heuristic scanner. Findings intentionally use wording such as "possible", "potential", and "appears to" when a check is heuristic.
|
|
274
274
|
|
|
275
275
|
Use it as a CI guardrail for common project hygiene and security mistakes. For production systems, combine it with code review, tests, dependency scanning, secrets scanning, threat modeling, and focused security assessment.
|
package/package.json
CHANGED
package/src/cli/terminal.js
CHANGED
|
@@ -1,95 +1,109 @@
|
|
|
1
|
-
import boxen from
|
|
2
|
-
import chalk, { Chalk } from
|
|
3
|
-
import figlet from
|
|
4
|
-
import gradient from
|
|
5
|
-
import ora from
|
|
1
|
+
import boxen from 'boxen';
|
|
2
|
+
import chalk, { Chalk } from 'chalk';
|
|
3
|
+
import figlet from 'figlet';
|
|
4
|
+
import gradient from 'gradient-string';
|
|
5
|
+
import ora from 'ora';
|
|
6
6
|
|
|
7
7
|
const SPINNER_TEXT = {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
git: 'Checking git hygiene',
|
|
9
|
+
env: 'Sniffing for secrets',
|
|
10
|
+
dependencies: 'Interrogating package.json',
|
|
11
|
+
ci: 'Inspecting CI rituals',
|
|
12
|
+
node: 'Poking the Node.js backend',
|
|
13
|
+
web: 'Looking for frontend footguns',
|
|
14
|
+
api: 'Testing the API trust issues',
|
|
15
|
+
database: 'Watching SQL strings misbehave',
|
|
16
|
+
electron: 'Opening the Electron danger drawer',
|
|
17
|
+
tauri: 'Reading Tauri permissions',
|
|
18
|
+
stress: 'Running controlled API stress test',
|
|
19
|
+
default: 'Looking for things that work but should not ship',
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export function isFancyOutputEnabled(options = {}, env = process.env, stdout = process.stdout) {
|
|
22
|
-
|
|
23
|
+
return Boolean(stdout.isTTY) && !env.CI && !options.json && !options.sarif && !options.todo && !options.noColor;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function isColorEnabled(options = {}, env = process.env, stdout = process.stdout) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
if (options.noColor || options.json || options.sarif || options.todo) return false;
|
|
28
|
+
if (env.FORCE_COLOR && env.FORCE_COLOR !== '0') return true;
|
|
29
|
+
if (env.CI) return false;
|
|
30
|
+
return Boolean(stdout.isTTY);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export function getChalk(options = {}) {
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
if (!isColorEnabled(options)) return new Chalk({ level: 0 });
|
|
35
|
+
return chalk;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export function shouldUseSpinner(options = {}, env = process.env, stdout = process.stdout) {
|
|
38
|
-
|
|
39
|
-
Boolean(stdout.isTTY) &&
|
|
40
|
-
!env.CI &&
|
|
41
|
-
!options.json &&
|
|
42
|
-
!options.sarif &&
|
|
43
|
-
!options.todo &&
|
|
44
|
-
!options.quiet
|
|
45
|
-
);
|
|
39
|
+
return Boolean(stdout.isTTY) && !env.CI && !options.json && !options.sarif && !options.todo && !options.quiet;
|
|
46
40
|
}
|
|
47
41
|
|
|
48
42
|
export function createScanSpinner(options = {}) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
43
|
+
if (!shouldUseSpinner(options)) return null;
|
|
44
|
+
return ora({
|
|
45
|
+
text: SPINNER_TEXT.default,
|
|
46
|
+
stream: process.stderr,
|
|
47
|
+
color: 'cyan',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createStressSpinner(options = {}) {
|
|
52
|
+
if (!shouldUseSpinner(options)) return null;
|
|
53
|
+
return ora({
|
|
54
|
+
text: SPINNER_TEXT.stress,
|
|
55
|
+
stream: process.stderr,
|
|
56
|
+
color: 'cyan',
|
|
57
|
+
});
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export function printIntro(options = {}) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
if (
|
|
62
|
+
options.json ||
|
|
63
|
+
options.sarif ||
|
|
64
|
+
options.todo ||
|
|
65
|
+
options.noBanner ||
|
|
66
|
+
options.quiet ||
|
|
67
|
+
process.env.CI ||
|
|
68
|
+
!process.stdout.isTTY
|
|
69
|
+
) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
const colors = getChalk(options);
|
|
74
|
+
const stress = options.command === 'stress';
|
|
75
|
+
const title = renderTitle('ItWorksBut', options.noColor);
|
|
76
|
+
const claim = stress
|
|
77
|
+
? `${colors.bold('Controlled API stress test.')}\n${colors.yellow('Only run this against systems you own or are authorized to test.')}`
|
|
78
|
+
: `${colors.bold('AI-built? Nice.')}\n${colors.yellow("Now let's see what breaks before production.")}`;
|
|
65
79
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
process.stdout.write(`${title}\n`);
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
`${boxen(claim, {
|
|
83
|
+
padding: 1,
|
|
84
|
+
margin: 1,
|
|
85
|
+
borderStyle: 'round',
|
|
86
|
+
borderColor: options.noColor ? undefined : 'cyan',
|
|
87
|
+
})}\n`,
|
|
88
|
+
);
|
|
75
89
|
}
|
|
76
90
|
|
|
77
|
-
function renderTitle(noColor) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
function renderTitle(value, noColor) {
|
|
92
|
+
let title = value;
|
|
93
|
+
try {
|
|
94
|
+
title = figlet.textSync(value, {
|
|
95
|
+
font: 'ANSI Shadow',
|
|
96
|
+
horizontalLayout: 'default',
|
|
97
|
+
verticalLayout: 'default',
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
title = 'ItWorksBut';
|
|
101
|
+
}
|
|
88
102
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
try {
|
|
104
|
+
if (noColor) return title;
|
|
105
|
+
return gradient.rainbow(title);
|
|
106
|
+
} catch {
|
|
107
|
+
return title;
|
|
108
|
+
}
|
|
95
109
|
}
|
package/src/commands/stress.js
CHANGED
|
@@ -6,16 +6,31 @@ import { runArtillery } from "../stress/artilleryRunner.js";
|
|
|
6
6
|
import { parseArtilleryResult } from "../stress/stressResultParser.js";
|
|
7
7
|
import { reportStressConsole } from "../stress/stressRenderer.js";
|
|
8
8
|
import { writeStressMarkdownReport } from "../reporters/stressMarkdownReport.js";
|
|
9
|
+
import { createStressSpinner, printIntro } from "../cli/terminal.js";
|
|
9
10
|
|
|
10
11
|
export async function runStressCommand(args, options = {}) {
|
|
11
12
|
if (args.todo || args.sarif) {
|
|
12
13
|
throw new Error("The stress command supports console output, --json, and --report.");
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
+
const stressOptions = {
|
|
16
17
|
rootPath: path.resolve(args.path || "."),
|
|
17
18
|
...validateStressOptions(args)
|
|
18
|
-
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
printIntro(args);
|
|
22
|
+
|
|
23
|
+
const spinner = createStressSpinner(args);
|
|
24
|
+
if (spinner) spinner.start();
|
|
25
|
+
|
|
26
|
+
let result;
|
|
27
|
+
try {
|
|
28
|
+
result = await runStress(stressOptions, options);
|
|
29
|
+
if (spinner) spinner.succeed("Stress test complete. Now the pressure readings.");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (spinner) spinner.fail("Stress test stopped before results were printed.");
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
19
34
|
|
|
20
35
|
if (args.json) {
|
|
21
36
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
@@ -2,10 +2,21 @@ import path from "node:path";
|
|
|
2
2
|
import { DEFAULT_IGNORE } from "../core/config.js";
|
|
3
3
|
import { walkProject } from "../core/fileWalker.js";
|
|
4
4
|
import { readFileSafe } from "../utils/fs.js";
|
|
5
|
+
import { isServerOrApiFile } from "../checks/helpers.js";
|
|
5
6
|
|
|
6
|
-
const HTTP_METHODS = ["get", "head", "post", "put", "patch", "delete"];
|
|
7
|
+
const HTTP_METHODS = ["get", "head", "post", "put", "patch", "delete", "options"];
|
|
7
8
|
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
8
|
-
const ROUTE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
|
|
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
|
+
];
|
|
9
20
|
const DISCOVERY_IGNORE = [
|
|
10
21
|
...DEFAULT_IGNORE,
|
|
11
22
|
"test/**",
|
|
@@ -28,15 +39,23 @@ export async function discoverEndpoints(rootPath) {
|
|
|
28
39
|
|
|
29
40
|
export async function discoverEndpointsFromFiles({ rootPath, files, readFile }) {
|
|
30
41
|
const endpoints = [];
|
|
42
|
+
const fileContents = [];
|
|
31
43
|
|
|
32
44
|
for (const file of files.filter(isSourceFile)) {
|
|
33
45
|
const content = await readFile(file);
|
|
34
46
|
if (content === null || content === undefined) continue;
|
|
47
|
+
fileContents.push({ file, content });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const mountPrefixes = collectMountPrefixes(fileContents);
|
|
35
51
|
|
|
36
|
-
|
|
52
|
+
for (const { file, content } of fileContents) {
|
|
53
|
+
endpoints.push(...discoverExpressEndpoints(file, content, mountPrefixes));
|
|
54
|
+
endpoints.push(...discoverMountedRouterEndpoints(file, content, mountPrefixes));
|
|
55
|
+
endpoints.push(...discoverFastifyRouteObjects(file, content));
|
|
37
56
|
endpoints.push(...discoverFetchReferences(file, content));
|
|
38
|
-
endpoints.push(...
|
|
39
|
-
endpoints.push(...
|
|
57
|
+
endpoints.push(...discoverFileConventionEndpoints(file, content));
|
|
58
|
+
endpoints.push(...discoverApiCandidateFileEndpoints(file, content));
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
return classifyEndpoints(dedupeEndpoints(endpoints), rootPath);
|
|
@@ -50,7 +69,10 @@ export function classifyEndpoints(endpoints) {
|
|
|
50
69
|
let status = "selected";
|
|
51
70
|
let reason;
|
|
52
71
|
|
|
53
|
-
if (
|
|
72
|
+
if (method === "OPTIONS") {
|
|
73
|
+
status = "skipped";
|
|
74
|
+
reason = "unsupported method";
|
|
75
|
+
} else if (unsafe) {
|
|
54
76
|
status = "skipped";
|
|
55
77
|
reason = "unsafe method";
|
|
56
78
|
} else if (dynamic) {
|
|
@@ -77,16 +99,60 @@ export function classifyEndpoints(endpoints) {
|
|
|
77
99
|
};
|
|
78
100
|
}
|
|
79
101
|
|
|
80
|
-
function discoverExpressEndpoints(file, content) {
|
|
102
|
+
function discoverExpressEndpoints(file, content, mountPrefixes = []) {
|
|
81
103
|
const endpoints = [];
|
|
82
104
|
const methods = HTTP_METHODS.join("|");
|
|
83
|
-
const regex = new RegExp(`\\b(
|
|
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);
|
|
84
108
|
let match;
|
|
85
109
|
|
|
86
110
|
while ((match = regex.exec(content)) !== null) {
|
|
87
|
-
|
|
111
|
+
if (!isRouteReceiver(match[1], receivers)) continue;
|
|
112
|
+
if (mountedRouterFile && isRouterReceiver(match[1], receivers)) continue;
|
|
113
|
+
const routePath = normalizeRoutePath(match[4]);
|
|
88
114
|
if (!routePath) continue;
|
|
89
|
-
endpoints.push(endpoint(match[
|
|
115
|
+
endpoints.push(endpoint(match[2], routePath, file, "express"));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return endpoints;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function discoverMountedRouterEndpoints(file, content, mountPrefixes) {
|
|
122
|
+
const applicableMounts = mountPrefixes.filter((mount) => mountAppliesToFile(mount, file));
|
|
123
|
+
if (applicableMounts.length === 0) return [];
|
|
124
|
+
|
|
125
|
+
const endpoints = [];
|
|
126
|
+
const methods = HTTP_METHODS.join("|");
|
|
127
|
+
const regex = new RegExp(`\\b([A-Za-z_$][\\w$]*)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\3`, "gi");
|
|
128
|
+
const receivers = collectRouteReceivers(content);
|
|
129
|
+
let match;
|
|
130
|
+
|
|
131
|
+
while ((match = regex.exec(content)) !== null) {
|
|
132
|
+
if (!isRouterReceiver(match[1], receivers)) continue;
|
|
133
|
+
const routePath = normalizeRoutePath(match[4]);
|
|
134
|
+
if (!routePath || routePath.startsWith("/api")) continue;
|
|
135
|
+
for (const mount of applicableMounts) {
|
|
136
|
+
endpoints.push(endpoint(match[2], joinRoutePath(mount.prefix, routePath), file, "mounted-router"));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return endpoints;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function discoverFastifyRouteObjects(file, content) {
|
|
144
|
+
const endpoints = [];
|
|
145
|
+
const regex = /\bfastify\s*\.\s*route\s*\(\s*\{([\s\S]*?)\}\s*\)/gi;
|
|
146
|
+
let match;
|
|
147
|
+
|
|
148
|
+
while ((match = regex.exec(content)) !== null) {
|
|
149
|
+
const objectText = match[1];
|
|
150
|
+
const methodMatch = objectText.match(/\bmethod\s*:\s*(['"`])(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\1/i);
|
|
151
|
+
const urlMatch = objectText.match(/\b(?:url|path)\s*:\s*(['"`])([^'"`]+)\1/i);
|
|
152
|
+
if (!methodMatch || !urlMatch) continue;
|
|
153
|
+
const routePath = normalizeRoutePath(urlMatch[2]);
|
|
154
|
+
if (!routePath) continue;
|
|
155
|
+
endpoints.push(endpoint(methodMatch[2], routePath, file, "fastify-route-object"));
|
|
90
156
|
}
|
|
91
157
|
|
|
92
158
|
return endpoints;
|
|
@@ -106,12 +172,22 @@ function discoverFetchReferences(file, content) {
|
|
|
106
172
|
return endpoints;
|
|
107
173
|
}
|
|
108
174
|
|
|
175
|
+
function discoverFileConventionEndpoints(file, content) {
|
|
176
|
+
return [
|
|
177
|
+
...discoverNextAppRouterEndpoints(file, content),
|
|
178
|
+
...discoverNextPagesRouterEndpoints(file, content),
|
|
179
|
+
...discoverSvelteKitEndpoints(file, content),
|
|
180
|
+
...discoverNitroEndpoints(file, content),
|
|
181
|
+
...discoverAstroApiEndpoints(file, content)
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
109
185
|
function discoverNextAppRouterEndpoints(file, content) {
|
|
110
186
|
const normalized = normalizeFilePath(file);
|
|
111
|
-
const match = normalized.match(/(?:^|\/)(?:src\/)?app\/
|
|
187
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?app\/(.+)\/route\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/);
|
|
112
188
|
if (!match) return [];
|
|
113
189
|
|
|
114
|
-
const routePath = normalizeRoutePath(
|
|
190
|
+
const routePath = normalizeRoutePath(`/${routeSegmentsToPath(match[1])}`);
|
|
115
191
|
const exportedMethods = discoverExportedRouteMethods(content);
|
|
116
192
|
const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
|
|
117
193
|
|
|
@@ -120,7 +196,7 @@ function discoverNextAppRouterEndpoints(file, content) {
|
|
|
120
196
|
|
|
121
197
|
function discoverNextPagesRouterEndpoints(file, content) {
|
|
122
198
|
const normalized = normalizeFilePath(file);
|
|
123
|
-
const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|jsx|ts|tsx|mjs|cjs)$/);
|
|
199
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/);
|
|
124
200
|
if (!match) return [];
|
|
125
201
|
|
|
126
202
|
const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
|
|
@@ -130,13 +206,67 @@ function discoverNextPagesRouterEndpoints(file, content) {
|
|
|
130
206
|
return methods.map((method) => endpoint(method, routePath, file, "next-pages-router"));
|
|
131
207
|
}
|
|
132
208
|
|
|
209
|
+
function discoverSvelteKitEndpoints(file, content) {
|
|
210
|
+
const normalized = normalizeFilePath(file);
|
|
211
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?routes\/(.+)\/\+server\.(?:js|ts|mjs|cjs|mts|cts)$/);
|
|
212
|
+
if (!match) return [];
|
|
213
|
+
|
|
214
|
+
const routePath = normalizeRoutePath(`/${routeSegmentsToPath(match[1])}`);
|
|
215
|
+
const exportedMethods = discoverExportedRouteMethods(content);
|
|
216
|
+
const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
|
|
217
|
+
|
|
218
|
+
return methods.map((method) => endpoint(method, routePath, file, "sveltekit-server-route"));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function discoverNitroEndpoints(file, content) {
|
|
222
|
+
const normalized = normalizeFilePath(file);
|
|
223
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?server\/api\/(.+?)(?:\.(get|head|post|put|patch|delete|options))?\.(?:js|ts|mjs|cjs|mts|cts)$/i);
|
|
224
|
+
if (!match) return [];
|
|
225
|
+
|
|
226
|
+
const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
|
|
227
|
+
const methods = match[2] ? [match[2].toUpperCase()] : discoverMethodGuards(content);
|
|
228
|
+
|
|
229
|
+
return (methods.length > 0 ? methods : ["GET"]).map((method) => endpoint(method, routePath, file, "nitro-server-api"));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function discoverAstroApiEndpoints(file, content) {
|
|
233
|
+
const normalized = normalizeFilePath(file);
|
|
234
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|ts|mjs|cjs|mts|cts)$/);
|
|
235
|
+
if (!match) return [];
|
|
236
|
+
|
|
237
|
+
const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
|
|
238
|
+
const exportedMethods = discoverExportedRouteMethods(content);
|
|
239
|
+
const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
|
|
240
|
+
|
|
241
|
+
return methods.map((method) => endpoint(method, routePath, file, "astro-api-route"));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function discoverApiCandidateFileEndpoints(file, content) {
|
|
245
|
+
if (!isSastApiCandidate(file, content)) return [];
|
|
246
|
+
if (hasExplicitRouteDeclaration(content) || isFileConventionRoute(file)) return [];
|
|
247
|
+
|
|
248
|
+
const routePath = inferRoutePathFromFile(file);
|
|
249
|
+
if (!routePath) return [];
|
|
250
|
+
|
|
251
|
+
const methods = discoverExportedRouteMethods(content);
|
|
252
|
+
const guardedMethods = discoverMethodGuards(content);
|
|
253
|
+
const inferredMethods = methods.length > 0 ? methods : guardedMethods;
|
|
254
|
+
|
|
255
|
+
return (inferredMethods.length > 0 ? inferredMethods : ["GET"]).map((method) => endpoint(method, routePath, file, "sast-api-candidate"));
|
|
256
|
+
}
|
|
257
|
+
|
|
133
258
|
function discoverExportedRouteMethods(content) {
|
|
134
259
|
const methods = new Set();
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
];
|
|
264
|
+
|
|
265
|
+
for (const regex of regexes) {
|
|
266
|
+
let match;
|
|
267
|
+
while ((match = regex.exec(content)) !== null) {
|
|
268
|
+
methods.add(match[1]);
|
|
269
|
+
}
|
|
140
270
|
}
|
|
141
271
|
|
|
142
272
|
return [...methods];
|
|
@@ -144,7 +274,7 @@ function discoverExportedRouteMethods(content) {
|
|
|
144
274
|
|
|
145
275
|
function discoverMethodGuards(content) {
|
|
146
276
|
const methods = new Set();
|
|
147
|
-
const regex = /\b(?:req|request)\.method\s*(?:===|!==|==|!=)\s*['"`](GET|HEAD|POST|PUT|PATCH|DELETE)['"`]/gi;
|
|
277
|
+
const regex = /\b(?:req|request)\.method\s*(?:===|!==|==|!=)\s*['"`](GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)['"`]/gi;
|
|
148
278
|
let match;
|
|
149
279
|
|
|
150
280
|
while ((match = regex.exec(content)) !== null) {
|
|
@@ -154,6 +284,131 @@ function discoverMethodGuards(content) {
|
|
|
154
284
|
return [...methods];
|
|
155
285
|
}
|
|
156
286
|
|
|
287
|
+
function collectMountPrefixes(fileContents) {
|
|
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);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
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
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
157
412
|
function endpoint(method, routePath, file, type) {
|
|
158
413
|
return {
|
|
159
414
|
method: method.toUpperCase(),
|
|
@@ -177,6 +432,113 @@ function isSourceFile(file) {
|
|
|
177
432
|
return ROUTE_EXTENSIONS.has(path.extname(normalized));
|
|
178
433
|
}
|
|
179
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
|
+
|
|
483
|
+
function isSastApiCandidate(file, content) {
|
|
484
|
+
const normalized = normalizeFilePath(file);
|
|
485
|
+
if (hasVendoredPathSegment(normalized)) return false;
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
isServerOrApiFile(normalized) ||
|
|
489
|
+
normalized.startsWith("api/") ||
|
|
490
|
+
normalized.startsWith("routes/") ||
|
|
491
|
+
normalized.startsWith("handlers/") ||
|
|
492
|
+
normalized.startsWith("controllers/") ||
|
|
493
|
+
normalized.includes("/handlers/") ||
|
|
494
|
+
normalized.includes("/controllers/")
|
|
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);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function hasExplicitRouteDeclaration(content) {
|
|
507
|
+
const methods = HTTP_METHODS.join("|");
|
|
508
|
+
return new RegExp(`\\b[A-Za-z_$][\\w$]*\\s*\\.\\s*(?:${methods}|route)\\s*\\(`, "i").test(content);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isFileConventionRoute(file) {
|
|
512
|
+
const normalized = normalizeFilePath(file);
|
|
513
|
+
return (
|
|
514
|
+
/(?:^|\/)(?:src\/)?app\/.+\/route\./.test(normalized) ||
|
|
515
|
+
/(?:^|\/)(?:src\/)?pages\/api\/.+\./.test(normalized) ||
|
|
516
|
+
/(?:^|\/)(?:src\/)?routes\/.+\/\+server\./.test(normalized) ||
|
|
517
|
+
/(?:^|\/)(?:src\/)?server\/api\/.+\./.test(normalized)
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function inferRoutePathFromFile(file) {
|
|
522
|
+
const normalized = normalizeFilePath(file);
|
|
523
|
+
const withoutExtension = normalized.replace(/\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/, "");
|
|
524
|
+
const patterns = [
|
|
525
|
+
{ regex: /(?:^|\/)(?:src\/)?api\/(.+)$/, prefix: "/api/" },
|
|
526
|
+
{ regex: /(?:^|\/)(?:src\/)?server\/api\/(.+)$/, prefix: "/api/" },
|
|
527
|
+
{ regex: /(?:^|\/)(?:src\/)?routes\/(.+)$/, prefix: "/" },
|
|
528
|
+
{ regex: /(?:^|\/)(?:src\/)?server\/routes\/(.+)$/, prefix: "/" },
|
|
529
|
+
{ regex: /(?:^|\/)(?:src\/)?handlers\/(.+)$/, prefix: "/" },
|
|
530
|
+
{ regex: /(?:^|\/)(?:src\/)?controllers\/(.+)$/, prefix: "/" }
|
|
531
|
+
];
|
|
532
|
+
|
|
533
|
+
for (const { regex, prefix } of patterns) {
|
|
534
|
+
const match = withoutExtension.match(regex);
|
|
535
|
+
if (!match) continue;
|
|
536
|
+
return normalizeRoutePath(`${prefix}${routeSegmentsToPath(match[1])}`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
180
542
|
function normalizeFilePath(file) {
|
|
181
543
|
return String(file).replace(/\\/g, "/");
|
|
182
544
|
}
|
|
@@ -187,11 +549,17 @@ function normalizeRoutePath(value) {
|
|
|
187
549
|
return pathValue.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
188
550
|
}
|
|
189
551
|
|
|
552
|
+
function joinRoutePath(prefix, routePath) {
|
|
553
|
+
return normalizeRoutePath(`${prefix}/${routePath.replace(/^\//, "")}`);
|
|
554
|
+
}
|
|
555
|
+
|
|
190
556
|
function routeSegmentsToPath(value) {
|
|
191
557
|
return value
|
|
192
558
|
.split("/")
|
|
193
559
|
.filter(Boolean)
|
|
194
|
-
.
|
|
560
|
+
.filter((segment) => !segment.startsWith("(") && !segment.startsWith("@") && !segment.startsWith("_"))
|
|
561
|
+
.map((segment) => segment.replace(/^\[\[?\.\.\.(.+)]]?$/, ":$1").replace(/^\[(.+)]$/, ":$1"))
|
|
562
|
+
.filter((segment) => segment !== "index")
|
|
195
563
|
.join("/");
|
|
196
564
|
}
|
|
197
565
|
|
|
@@ -1,80 +1,94 @@
|
|
|
1
|
-
import { getChalk } from
|
|
1
|
+
import { getChalk, isFancyOutputEnabled } from '../cli/terminal.js';
|
|
2
2
|
|
|
3
3
|
export function reportStressConsole(result, options = {}) {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
const colors = getChalk(options);
|
|
5
|
+
const details = result.details || {};
|
|
6
|
+
const rich = isFancyOutputEnabled(options);
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
if (!options.quiet) {
|
|
9
|
+
if (!rich) process.stdout.write(`${colors.bold('ItWorksBut Stress')}\n\n`);
|
|
10
|
+
process.stdout.write('Only run this against systems you own or are explicitly authorized to test.\n\n');
|
|
11
|
+
process.stdout.write(`Target: ${details.target}\n`);
|
|
12
|
+
process.stdout.write(`Duration: ${details.duration}s\n`);
|
|
13
|
+
process.stdout.write(`Arrival rate: ${details.arrivalRate} req/s\n`);
|
|
14
|
+
process.stdout.write(`Max virtual users: ${details.maxVusers}\n\n`);
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
writeDiscovery(details, colors);
|
|
17
|
+
writeEndpoints(details, colors);
|
|
18
|
+
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
writeSummary(result, colors);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
function writeDiscovery(details, colors) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
const found = details.endpointsFound || 0;
|
|
25
|
+
const safe = details.safeEndpoints || 0;
|
|
26
|
+
const skipped = details.skippedEndpoints?.length || 0;
|
|
27
|
+
const unsafe = details.skippedEndpoints?.filter(endpoint => endpoint.reason === 'unsafe method').length || 0;
|
|
28
|
+
const dynamic =
|
|
29
|
+
details.skippedEndpoints?.filter(endpoint => endpoint.reason === 'dynamic route requires parameter').length ||
|
|
30
|
+
0;
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
process.stdout.write(
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
process.stdout.write(
|
|
33
|
+
`${colors.green('✓')} Endpoint discovery: ${found} ${found === 1 ? 'endpoint' : 'endpoints'} found\n`,
|
|
34
|
+
);
|
|
35
|
+
process.stdout.write(
|
|
36
|
+
`${colors.green('✓')} Safe endpoints: ${safe} GET/HEAD ${safe === 1 ? 'endpoint' : 'endpoints'} selected\n`,
|
|
37
|
+
);
|
|
38
|
+
if (skipped > 0) {
|
|
39
|
+
process.stdout.write(`- Skipped: ${unsafe} unsafe methods, ${dynamic} dynamic routes\n`);
|
|
40
|
+
}
|
|
41
|
+
process.stdout.write('\n');
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
function writeEndpoints(details, colors) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
const testedEndpoints = details.testedEndpoints || [];
|
|
46
|
+
if (testedEndpoints.length === 0) {
|
|
47
|
+
process.stdout.write('Running Artillery: skipped\n\n');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
process.stdout.write('Running Artillery: complete\n\n');
|
|
52
|
+
for (const endpoint of testedEndpoints) {
|
|
53
|
+
const symbol =
|
|
54
|
+
endpoint.status === 'pass'
|
|
55
|
+
? colors.green('✓')
|
|
56
|
+
: endpoint.status === 'warn'
|
|
57
|
+
? colors.yellow('⚠')
|
|
58
|
+
: colors.red('✕');
|
|
59
|
+
process.stdout.write(`${symbol} ${endpoint.method} ${endpoint.path}\n`);
|
|
60
|
+
process.stdout.write(` p95: ${formatMs(endpoint.p95)}\n`);
|
|
61
|
+
process.stdout.write(` p99: ${formatMs(endpoint.p99)}\n`);
|
|
62
|
+
process.stdout.write(` errors: ${endpoint.errors}\n`);
|
|
63
|
+
process.stdout.write(` error rate: ${formatPercent(endpoint.errorRate)}\n\n`);
|
|
64
|
+
}
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
function writeSummary(result, colors) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
const details = result.details || {};
|
|
69
|
+
const warnings = details.warnings || 0;
|
|
70
|
+
const failed = details.failed || 0;
|
|
71
|
+
const tested = details.testedEndpoints?.length || 0;
|
|
72
|
+
const skipped = details.skippedEndpoints?.length || 0;
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
process.stdout.write('Summary:\n');
|
|
75
|
+
process.stdout.write(`${colors.green('✓')} Tested endpoints: ${tested}\n`);
|
|
76
|
+
process.stdout.write(`${colors.yellow('⚠')} Warnings: ${warnings}\n`);
|
|
77
|
+
process.stdout.write(`${colors.red('✕')} Failed: ${failed}\n`);
|
|
78
|
+
process.stdout.write(`- Skipped: ${skipped}\n`);
|
|
79
|
+
process.stdout.write(`- Status: ${result.status}\n`);
|
|
80
|
+
process.stdout.write(`- ${result.summary}\n`);
|
|
81
|
+
if (details.artilleryError) {
|
|
82
|
+
process.stdout.write(`- Artillery error: ${details.artilleryError}\n`);
|
|
83
|
+
}
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
function formatMs(value) {
|
|
75
|
-
|
|
87
|
+
return value === null || value === undefined ? 'n/a' : `${Math.round(value)} ms`;
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
function formatPercent(value) {
|
|
79
|
-
|
|
91
|
+
return `${Number(value || 0)
|
|
92
|
+
.toFixed(Number.isInteger(value) ? 0 : 2)
|
|
93
|
+
.replace(/\.00$/, '')}%`;
|
|
80
94
|
}
|
|
@@ -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
|
}
|