itworksbut 0.7.1 → 0.7.2
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 +194 -12
- package/src/stress/stressRenderer.js +70 -56
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,11 @@ 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"]);
|
|
9
10
|
const DISCOVERY_IGNORE = [
|
|
10
11
|
...DEFAULT_IGNORE,
|
|
11
12
|
"test/**",
|
|
@@ -28,15 +29,23 @@ export async function discoverEndpoints(rootPath) {
|
|
|
28
29
|
|
|
29
30
|
export async function discoverEndpointsFromFiles({ rootPath, files, readFile }) {
|
|
30
31
|
const endpoints = [];
|
|
32
|
+
const fileContents = [];
|
|
31
33
|
|
|
32
34
|
for (const file of files.filter(isSourceFile)) {
|
|
33
35
|
const content = await readFile(file);
|
|
34
36
|
if (content === null || content === undefined) continue;
|
|
37
|
+
fileContents.push({ file, content });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mountPrefixes = collectMountPrefixes(fileContents);
|
|
35
41
|
|
|
42
|
+
for (const { file, content } of fileContents) {
|
|
36
43
|
endpoints.push(...discoverExpressEndpoints(file, content));
|
|
44
|
+
endpoints.push(...discoverMountedRouterEndpoints(file, content, mountPrefixes));
|
|
45
|
+
endpoints.push(...discoverFastifyRouteObjects(file, content));
|
|
37
46
|
endpoints.push(...discoverFetchReferences(file, content));
|
|
38
|
-
endpoints.push(...
|
|
39
|
-
endpoints.push(...
|
|
47
|
+
endpoints.push(...discoverFileConventionEndpoints(file, content));
|
|
48
|
+
endpoints.push(...discoverApiCandidateFileEndpoints(file, content));
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
return classifyEndpoints(dedupeEndpoints(endpoints), rootPath);
|
|
@@ -50,7 +59,10 @@ export function classifyEndpoints(endpoints) {
|
|
|
50
59
|
let status = "selected";
|
|
51
60
|
let reason;
|
|
52
61
|
|
|
53
|
-
if (
|
|
62
|
+
if (method === "OPTIONS") {
|
|
63
|
+
status = "skipped";
|
|
64
|
+
reason = "unsupported method";
|
|
65
|
+
} else if (unsafe) {
|
|
54
66
|
status = "skipped";
|
|
55
67
|
reason = "unsafe method";
|
|
56
68
|
} else if (dynamic) {
|
|
@@ -80,7 +92,7 @@ export function classifyEndpoints(endpoints) {
|
|
|
80
92
|
function discoverExpressEndpoints(file, content) {
|
|
81
93
|
const endpoints = [];
|
|
82
94
|
const methods = HTTP_METHODS.join("|");
|
|
83
|
-
const regex = new RegExp(`\\b
|
|
95
|
+
const regex = new RegExp(`\\b[A-Za-z_$][\\w$]*\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "gi");
|
|
84
96
|
let match;
|
|
85
97
|
|
|
86
98
|
while ((match = regex.exec(content)) !== null) {
|
|
@@ -92,6 +104,43 @@ function discoverExpressEndpoints(file, content) {
|
|
|
92
104
|
return endpoints;
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
function discoverMountedRouterEndpoints(file, content, mountPrefixes) {
|
|
108
|
+
if (mountPrefixes.length === 0) return [];
|
|
109
|
+
|
|
110
|
+
const endpoints = [];
|
|
111
|
+
const methods = HTTP_METHODS.join("|");
|
|
112
|
+
const regex = new RegExp(`\\b(?:router|Router|apiRouter|routes|route)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "gi");
|
|
113
|
+
let match;
|
|
114
|
+
|
|
115
|
+
while ((match = regex.exec(content)) !== null) {
|
|
116
|
+
const routePath = normalizeRoutePath(match[3]);
|
|
117
|
+
if (!routePath || routePath.startsWith("/api")) continue;
|
|
118
|
+
for (const prefix of mountPrefixes) {
|
|
119
|
+
endpoints.push(endpoint(match[1], joinRoutePath(prefix, routePath), file, "mounted-router"));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return endpoints;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function discoverFastifyRouteObjects(file, content) {
|
|
127
|
+
const endpoints = [];
|
|
128
|
+
const regex = /\bfastify\s*\.\s*route\s*\(\s*\{([\s\S]*?)\}\s*\)/gi;
|
|
129
|
+
let match;
|
|
130
|
+
|
|
131
|
+
while ((match = regex.exec(content)) !== null) {
|
|
132
|
+
const objectText = match[1];
|
|
133
|
+
const methodMatch = objectText.match(/\bmethod\s*:\s*(['"`])(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\1/i);
|
|
134
|
+
const urlMatch = objectText.match(/\b(?:url|path)\s*:\s*(['"`])([^'"`]+)\1/i);
|
|
135
|
+
if (!methodMatch || !urlMatch) continue;
|
|
136
|
+
const routePath = normalizeRoutePath(urlMatch[2]);
|
|
137
|
+
if (!routePath) continue;
|
|
138
|
+
endpoints.push(endpoint(methodMatch[2], routePath, file, "fastify-route-object"));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return endpoints;
|
|
142
|
+
}
|
|
143
|
+
|
|
95
144
|
function discoverFetchReferences(file, content) {
|
|
96
145
|
const endpoints = [];
|
|
97
146
|
const regex = /\bfetch\s*\(\s*(['"`])(\/api\/[^'"`]+)\1/gi;
|
|
@@ -106,12 +155,22 @@ function discoverFetchReferences(file, content) {
|
|
|
106
155
|
return endpoints;
|
|
107
156
|
}
|
|
108
157
|
|
|
158
|
+
function discoverFileConventionEndpoints(file, content) {
|
|
159
|
+
return [
|
|
160
|
+
...discoverNextAppRouterEndpoints(file, content),
|
|
161
|
+
...discoverNextPagesRouterEndpoints(file, content),
|
|
162
|
+
...discoverSvelteKitEndpoints(file, content),
|
|
163
|
+
...discoverNitroEndpoints(file, content),
|
|
164
|
+
...discoverAstroApiEndpoints(file, content)
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
109
168
|
function discoverNextAppRouterEndpoints(file, content) {
|
|
110
169
|
const normalized = normalizeFilePath(file);
|
|
111
|
-
const match = normalized.match(/(?:^|\/)(?:src\/)?app\/
|
|
170
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?app\/(.+)\/route\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/);
|
|
112
171
|
if (!match) return [];
|
|
113
172
|
|
|
114
|
-
const routePath = normalizeRoutePath(
|
|
173
|
+
const routePath = normalizeRoutePath(`/${routeSegmentsToPath(match[1])}`);
|
|
115
174
|
const exportedMethods = discoverExportedRouteMethods(content);
|
|
116
175
|
const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
|
|
117
176
|
|
|
@@ -120,7 +179,7 @@ function discoverNextAppRouterEndpoints(file, content) {
|
|
|
120
179
|
|
|
121
180
|
function discoverNextPagesRouterEndpoints(file, content) {
|
|
122
181
|
const normalized = normalizeFilePath(file);
|
|
123
|
-
const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|jsx|ts|tsx|mjs|cjs)$/);
|
|
182
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/);
|
|
124
183
|
if (!match) return [];
|
|
125
184
|
|
|
126
185
|
const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
|
|
@@ -130,9 +189,58 @@ function discoverNextPagesRouterEndpoints(file, content) {
|
|
|
130
189
|
return methods.map((method) => endpoint(method, routePath, file, "next-pages-router"));
|
|
131
190
|
}
|
|
132
191
|
|
|
192
|
+
function discoverSvelteKitEndpoints(file, content) {
|
|
193
|
+
const normalized = normalizeFilePath(file);
|
|
194
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?routes\/(.+)\/\+server\.(?:js|ts|mjs|cjs|mts|cts)$/);
|
|
195
|
+
if (!match) return [];
|
|
196
|
+
|
|
197
|
+
const routePath = normalizeRoutePath(`/${routeSegmentsToPath(match[1])}`);
|
|
198
|
+
const exportedMethods = discoverExportedRouteMethods(content);
|
|
199
|
+
const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
|
|
200
|
+
|
|
201
|
+
return methods.map((method) => endpoint(method, routePath, file, "sveltekit-server-route"));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function discoverNitroEndpoints(file, content) {
|
|
205
|
+
const normalized = normalizeFilePath(file);
|
|
206
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?server\/api\/(.+?)(?:\.(get|head|post|put|patch|delete|options))?\.(?:js|ts|mjs|cjs|mts|cts)$/i);
|
|
207
|
+
if (!match) return [];
|
|
208
|
+
|
|
209
|
+
const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
|
|
210
|
+
const methods = match[2] ? [match[2].toUpperCase()] : discoverMethodGuards(content);
|
|
211
|
+
|
|
212
|
+
return (methods.length > 0 ? methods : ["GET"]).map((method) => endpoint(method, routePath, file, "nitro-server-api"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function discoverAstroApiEndpoints(file, content) {
|
|
216
|
+
const normalized = normalizeFilePath(file);
|
|
217
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|ts|mjs|cjs|mts|cts)$/);
|
|
218
|
+
if (!match) return [];
|
|
219
|
+
|
|
220
|
+
const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
|
|
221
|
+
const exportedMethods = discoverExportedRouteMethods(content);
|
|
222
|
+
const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
|
|
223
|
+
|
|
224
|
+
return methods.map((method) => endpoint(method, routePath, file, "astro-api-route"));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function discoverApiCandidateFileEndpoints(file, content) {
|
|
228
|
+
if (!isSastApiCandidate(file, content)) return [];
|
|
229
|
+
if (hasExplicitRouteDeclaration(content) || isFileConventionRoute(file)) return [];
|
|
230
|
+
|
|
231
|
+
const routePath = inferRoutePathFromFile(file);
|
|
232
|
+
if (!routePath) return [];
|
|
233
|
+
|
|
234
|
+
const methods = discoverExportedRouteMethods(content);
|
|
235
|
+
const guardedMethods = discoverMethodGuards(content);
|
|
236
|
+
const inferredMethods = methods.length > 0 ? methods : guardedMethods;
|
|
237
|
+
|
|
238
|
+
return (inferredMethods.length > 0 ? inferredMethods : ["GET"]).map((method) => endpoint(method, routePath, file, "sast-api-candidate"));
|
|
239
|
+
}
|
|
240
|
+
|
|
133
241
|
function discoverExportedRouteMethods(content) {
|
|
134
242
|
const methods = new Set();
|
|
135
|
-
const regex = /\bexport\s+(?:async\s+)?function\s+(GET|HEAD|POST|PUT|PATCH|DELETE)\b/g;
|
|
243
|
+
const regex = /\bexport\s+(?:async\s+)?function\s+(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\b/g;
|
|
136
244
|
let match;
|
|
137
245
|
|
|
138
246
|
while ((match = regex.exec(content)) !== null) {
|
|
@@ -144,7 +252,7 @@ function discoverExportedRouteMethods(content) {
|
|
|
144
252
|
|
|
145
253
|
function discoverMethodGuards(content) {
|
|
146
254
|
const methods = new Set();
|
|
147
|
-
const regex = /\b(?:req|request)\.method\s*(?:===|!==|==|!=)\s*['"`](GET|HEAD|POST|PUT|PATCH|DELETE)['"`]/gi;
|
|
255
|
+
const regex = /\b(?:req|request)\.method\s*(?:===|!==|==|!=)\s*['"`](GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)['"`]/gi;
|
|
148
256
|
let match;
|
|
149
257
|
|
|
150
258
|
while ((match = regex.exec(content)) !== null) {
|
|
@@ -154,6 +262,25 @@ function discoverMethodGuards(content) {
|
|
|
154
262
|
return [...methods];
|
|
155
263
|
}
|
|
156
264
|
|
|
265
|
+
function collectMountPrefixes(fileContents) {
|
|
266
|
+
const prefixes = new Set();
|
|
267
|
+
const useRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*use\s*\(\s*(['"`])([^'"`]+)\1\s*,/gi;
|
|
268
|
+
const registerRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*register\s*\([^)]*\{[^}]*\bprefix\s*:\s*(['"`])([^'"`]+)\1/gi;
|
|
269
|
+
|
|
270
|
+
for (const { content } of fileContents) {
|
|
271
|
+
for (const regex of [useRegex, registerRegex]) {
|
|
272
|
+
regex.lastIndex = 0;
|
|
273
|
+
let match;
|
|
274
|
+
while ((match = regex.exec(content)) !== null) {
|
|
275
|
+
const prefix = normalizeRoutePath(match[2]);
|
|
276
|
+
if (prefix && prefix !== "/") prefixes.add(prefix);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return [...prefixes].sort();
|
|
282
|
+
}
|
|
283
|
+
|
|
157
284
|
function endpoint(method, routePath, file, type) {
|
|
158
285
|
return {
|
|
159
286
|
method: method.toUpperCase(),
|
|
@@ -177,6 +304,55 @@ function isSourceFile(file) {
|
|
|
177
304
|
return ROUTE_EXTENSIONS.has(path.extname(normalized));
|
|
178
305
|
}
|
|
179
306
|
|
|
307
|
+
function isSastApiCandidate(file, content) {
|
|
308
|
+
const normalized = normalizeFilePath(file);
|
|
309
|
+
return (
|
|
310
|
+
isServerOrApiFile(normalized) ||
|
|
311
|
+
normalized.startsWith("api/") ||
|
|
312
|
+
normalized.startsWith("routes/") ||
|
|
313
|
+
normalized.startsWith("handlers/") ||
|
|
314
|
+
normalized.startsWith("controllers/") ||
|
|
315
|
+
normalized.includes("/handlers/") ||
|
|
316
|
+
normalized.includes("/controllers/")
|
|
317
|
+
) && /\b(?:req|request|res|response|ctx|context|NextRequest|NextResponse|Response|json)\b/.test(content);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function hasExplicitRouteDeclaration(content) {
|
|
321
|
+
const methods = HTTP_METHODS.join("|");
|
|
322
|
+
return new RegExp(`\\b[A-Za-z_$][\\w$]*\\s*\\.\\s*(?:${methods}|route)\\s*\\(`, "i").test(content);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isFileConventionRoute(file) {
|
|
326
|
+
const normalized = normalizeFilePath(file);
|
|
327
|
+
return (
|
|
328
|
+
/(?:^|\/)(?:src\/)?app\/.+\/route\./.test(normalized) ||
|
|
329
|
+
/(?:^|\/)(?:src\/)?pages\/api\/.+\./.test(normalized) ||
|
|
330
|
+
/(?:^|\/)(?:src\/)?routes\/.+\/\+server\./.test(normalized) ||
|
|
331
|
+
/(?:^|\/)(?:src\/)?server\/api\/.+\./.test(normalized)
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function inferRoutePathFromFile(file) {
|
|
336
|
+
const normalized = normalizeFilePath(file);
|
|
337
|
+
const withoutExtension = normalized.replace(/\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/, "");
|
|
338
|
+
const patterns = [
|
|
339
|
+
{ regex: /(?:^|\/)(?:src\/)?api\/(.+)$/, prefix: "/api/" },
|
|
340
|
+
{ regex: /(?:^|\/)(?:src\/)?server\/api\/(.+)$/, prefix: "/api/" },
|
|
341
|
+
{ regex: /(?:^|\/)(?:src\/)?routes\/(.+)$/, prefix: "/" },
|
|
342
|
+
{ regex: /(?:^|\/)(?:src\/)?server\/routes\/(.+)$/, prefix: "/" },
|
|
343
|
+
{ regex: /(?:^|\/)(?:src\/)?handlers\/(.+)$/, prefix: "/" },
|
|
344
|
+
{ regex: /(?:^|\/)(?:src\/)?controllers\/(.+)$/, prefix: "/" }
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
for (const { regex, prefix } of patterns) {
|
|
348
|
+
const match = withoutExtension.match(regex);
|
|
349
|
+
if (!match) continue;
|
|
350
|
+
return normalizeRoutePath(`${prefix}${routeSegmentsToPath(match[1])}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
180
356
|
function normalizeFilePath(file) {
|
|
181
357
|
return String(file).replace(/\\/g, "/");
|
|
182
358
|
}
|
|
@@ -187,11 +363,17 @@ function normalizeRoutePath(value) {
|
|
|
187
363
|
return pathValue.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
188
364
|
}
|
|
189
365
|
|
|
366
|
+
function joinRoutePath(prefix, routePath) {
|
|
367
|
+
return normalizeRoutePath(`${prefix}/${routePath.replace(/^\//, "")}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
190
370
|
function routeSegmentsToPath(value) {
|
|
191
371
|
return value
|
|
192
372
|
.split("/")
|
|
193
373
|
.filter(Boolean)
|
|
194
|
-
.
|
|
374
|
+
.filter((segment) => !segment.startsWith("(") && !segment.startsWith("@") && !segment.startsWith("_"))
|
|
375
|
+
.map((segment) => segment.replace(/^\[\[?\.\.\.(.+)]]?$/, ":$1").replace(/^\[(.+)]$/, ":$1"))
|
|
376
|
+
.filter((segment) => segment !== "index")
|
|
195
377
|
.join("/");
|
|
196
378
|
}
|
|
197
379
|
|
|
@@ -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
|
}
|