devspy-tool 1.0.0
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/bin/cli.js +56 -0
- package/config.js +6 -0
- package/dist/assets/index-BPZbQMS8.js +12 -0
- package/dist/assets/index-BeP94nNh.css +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +24 -0
- package/package.json +36 -0
- package/puppeteer/debug.js +82 -0
- package/puppeteer/explorer.js +507 -0
- package/puppeteer/interceptor.js +622 -0
- package/puppeteer/launcher.js +30 -0
- package/puppeteer/network.js +253 -0
- package/puppeteer/sessionStore.js +140 -0
- package/routes/scan.js +334 -0
- package/server.js +44 -0
package/routes/scan.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
const express = require("express");
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const { SessionEngine } = require("../puppeteer/interceptor");
|
|
4
|
+
const { sessionStore, SESSION_TTL_MS } = require("../puppeteer/sessionStore");
|
|
5
|
+
|
|
6
|
+
// ─── Active manual sessions (one at a time for local dev tool) ──────────────
|
|
7
|
+
let activeSession = null;
|
|
8
|
+
let activeManualScanId = null;
|
|
9
|
+
|
|
10
|
+
function isValidUrl(string) {
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(string);
|
|
13
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── POST /api/scan — Automated scan ────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
router.post("/", async (req, res) => {
|
|
22
|
+
const {
|
|
23
|
+
url,
|
|
24
|
+
waitTime = 10000,
|
|
25
|
+
login = null,
|
|
26
|
+
explorePages = false,
|
|
27
|
+
postLoginUrl = null,
|
|
28
|
+
} = req.body;
|
|
29
|
+
|
|
30
|
+
if (!url) {
|
|
31
|
+
return res.status(400).json({ success: false, error: "URL is required." });
|
|
32
|
+
}
|
|
33
|
+
if (!isValidUrl(url)) {
|
|
34
|
+
return res.status(400).json({
|
|
35
|
+
success: false,
|
|
36
|
+
error: "Invalid URL. Must start with http:// or https://",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const engine = new SessionEngine();
|
|
42
|
+
|
|
43
|
+
// keepAlive=true so the browser stays open for replay
|
|
44
|
+
const requests = await engine.runAutoScan(url, {
|
|
45
|
+
waitTime,
|
|
46
|
+
login,
|
|
47
|
+
deepExplore: explorePages,
|
|
48
|
+
postLoginUrl,
|
|
49
|
+
keepAlive: true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Store the session for replay
|
|
53
|
+
const scanId = sessionStore.store(engine);
|
|
54
|
+
|
|
55
|
+
return res.json({
|
|
56
|
+
success: true,
|
|
57
|
+
scanId,
|
|
58
|
+
scannedUrl: url,
|
|
59
|
+
count: requests.length,
|
|
60
|
+
requests,
|
|
61
|
+
sessionTTL: SESSION_TTL_MS,
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error("[Scan Error]", err.message);
|
|
65
|
+
return res.status(500).json({
|
|
66
|
+
success: false,
|
|
67
|
+
error: "Scan failed. " + err.message,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ─── POST /api/scan/manual — Start manual session ───────────────────────────
|
|
73
|
+
|
|
74
|
+
router.post("/manual", async (req, res) => {
|
|
75
|
+
if (process.env.RENDER) {
|
|
76
|
+
return res.status(403).json({
|
|
77
|
+
success: false,
|
|
78
|
+
error: "Manual mode is only available when running DevSpy locally via npx due to browser requirements.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Only one manual session at a time
|
|
83
|
+
if (activeSession && activeSession.isRunning) {
|
|
84
|
+
return res.status(409).json({
|
|
85
|
+
success: false,
|
|
86
|
+
error: "A manual session is already running. Stop it first.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { url, login = null, postLoginUrl = null } = req.body;
|
|
91
|
+
|
|
92
|
+
if (!url || !isValidUrl(url)) {
|
|
93
|
+
return res.status(400).json({ success: false, error: "Valid URL is required." });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
activeSession = new SessionEngine();
|
|
98
|
+
await activeSession.startManualSession(url, { login, postLoginUrl });
|
|
99
|
+
|
|
100
|
+
return res.json({
|
|
101
|
+
success: true,
|
|
102
|
+
message: "Manual session started. Browser is open.",
|
|
103
|
+
streamUrl: "/api/scan/manual/stream",
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error("[Manual Start Error]", err.message);
|
|
107
|
+
activeSession = null;
|
|
108
|
+
return res.status(500).json({
|
|
109
|
+
success: false,
|
|
110
|
+
error: "Failed to start manual session: " + err.message,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ─── GET /api/scan/manual/stream — SSE live stream of captured APIs ─────────
|
|
116
|
+
|
|
117
|
+
router.get("/manual/stream", (req, res) => {
|
|
118
|
+
if (!activeSession || !activeSession.isRunning) {
|
|
119
|
+
return res.status(404).json({ success: false, error: "No active manual session." });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Set up SSE
|
|
123
|
+
res.writeHead(200, {
|
|
124
|
+
"Content-Type": "text/event-stream",
|
|
125
|
+
"Cache-Control": "no-cache",
|
|
126
|
+
Connection: "keep-alive",
|
|
127
|
+
"Access-Control-Allow-Origin": process.env.FRONTEND_URL || "*",
|
|
128
|
+
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept",
|
|
129
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Send initial count
|
|
133
|
+
res.write(`data: ${JSON.stringify({ type: "init", count: activeSession.captureCount })}\n\n`);
|
|
134
|
+
|
|
135
|
+
// Stream new captures
|
|
136
|
+
const onCapture = (entry) => {
|
|
137
|
+
res.write(`data: ${JSON.stringify({ type: "api", entry })}\n\n`);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
activeSession.on("apiCaptured", onCapture);
|
|
141
|
+
|
|
142
|
+
// Heartbeat every 15s to keep connection alive
|
|
143
|
+
const heartbeat = setInterval(() => {
|
|
144
|
+
res.write(`data: ${JSON.stringify({ type: "heartbeat", count: activeSession?.captureCount || 0 })}\n\n`);
|
|
145
|
+
}, 15000);
|
|
146
|
+
|
|
147
|
+
// Cleanup on disconnect
|
|
148
|
+
req.on("close", () => {
|
|
149
|
+
clearInterval(heartbeat);
|
|
150
|
+
if (activeSession) {
|
|
151
|
+
activeSession.removeListener("apiCaptured", onCapture);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─── POST /api/scan/manual/stop — Stop manual session ───────────────────────
|
|
157
|
+
|
|
158
|
+
router.post("/manual/stop", async (req, res) => {
|
|
159
|
+
if (!activeSession || !activeSession.isRunning) {
|
|
160
|
+
return res.status(404).json({ success: false, error: "No active manual session." });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
// keepAlive=true so the browser stays open for replay
|
|
165
|
+
const results = await activeSession.stopManualSession(true);
|
|
166
|
+
|
|
167
|
+
// Store the session for replay
|
|
168
|
+
const scanId = sessionStore.store(activeSession);
|
|
169
|
+
activeManualScanId = scanId;
|
|
170
|
+
activeSession = null;
|
|
171
|
+
|
|
172
|
+
return res.json({
|
|
173
|
+
success: true,
|
|
174
|
+
scanId,
|
|
175
|
+
count: results.length,
|
|
176
|
+
requests: results,
|
|
177
|
+
sessionTTL: SESSION_TTL_MS,
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error("[Manual Stop Error]", err.message);
|
|
181
|
+
activeSession = null;
|
|
182
|
+
return res.status(500).json({
|
|
183
|
+
success: false,
|
|
184
|
+
error: "Failed to stop session: " + err.message,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ─── GET /api/scan/manual/status — Check session status ─────────────────────
|
|
190
|
+
|
|
191
|
+
router.get("/manual/status", (req, res) => {
|
|
192
|
+
res.json({
|
|
193
|
+
success: true,
|
|
194
|
+
running: !!activeSession?.isRunning,
|
|
195
|
+
count: activeSession?.captureCount || 0,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ─── POST /api/scan/replay — Session-aware replay ───────────────────────────
|
|
200
|
+
//
|
|
201
|
+
// Two modes:
|
|
202
|
+
// 1. scanId provided → replay inside Puppeteer page context (authenticated)
|
|
203
|
+
// 2. No scanId → plain server-side fetch (fallback, may fail for auth APIs)
|
|
204
|
+
|
|
205
|
+
router.post("/replay", async (req, res) => {
|
|
206
|
+
const { scanId, url, method = "GET", headers = {}, body } = req.body;
|
|
207
|
+
|
|
208
|
+
if (!url) {
|
|
209
|
+
return res.status(400).json({ success: false, error: "URL is required." });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Mode 1: Session-aware replay ──
|
|
213
|
+
if (scanId) {
|
|
214
|
+
const engine = sessionStore.get(scanId);
|
|
215
|
+
|
|
216
|
+
if (!engine) {
|
|
217
|
+
return res.status(404).json({
|
|
218
|
+
success: false,
|
|
219
|
+
error: "Session expired or not found. Try a plain replay or re-scan.",
|
|
220
|
+
sessionExpired: true,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!engine.isAlive) {
|
|
225
|
+
await sessionStore.destroy(scanId);
|
|
226
|
+
return res.status(410).json({
|
|
227
|
+
success: false,
|
|
228
|
+
error: "Browser session has closed. Re-scan to create a new session.",
|
|
229
|
+
sessionExpired: true,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const result = await engine.replayInContext({
|
|
235
|
+
url,
|
|
236
|
+
method,
|
|
237
|
+
headers: headers || {},
|
|
238
|
+
body: body || undefined,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Extend the session TTL
|
|
242
|
+
sessionStore.touch(scanId);
|
|
243
|
+
|
|
244
|
+
return res.json({
|
|
245
|
+
success: true,
|
|
246
|
+
mode: "session",
|
|
247
|
+
status: result.status,
|
|
248
|
+
statusText: result.statusText,
|
|
249
|
+
headers: result.headers,
|
|
250
|
+
body: result.body,
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.error("[Replay Error]", err.message);
|
|
254
|
+
return res.status(500).json({
|
|
255
|
+
success: false,
|
|
256
|
+
mode: "session",
|
|
257
|
+
error: "Session replay failed: " + err.message,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Mode 2: Plain server-side fetch (fallback) ──
|
|
263
|
+
try {
|
|
264
|
+
const fetchOptions = {
|
|
265
|
+
method,
|
|
266
|
+
headers: headers || {},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Attach body for non-GET/HEAD requests
|
|
270
|
+
if (body && method !== "GET" && method !== "HEAD") {
|
|
271
|
+
fetchOptions.body =
|
|
272
|
+
typeof body === "string" ? body : JSON.stringify(body);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const upstream = await fetch(url, fetchOptions);
|
|
276
|
+
|
|
277
|
+
// Collect response headers
|
|
278
|
+
const responseHeaders = {};
|
|
279
|
+
upstream.headers.forEach((value, key) => {
|
|
280
|
+
responseHeaders[key] = value;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let responseBody;
|
|
284
|
+
try {
|
|
285
|
+
responseBody = await upstream.text();
|
|
286
|
+
} catch {
|
|
287
|
+
responseBody = "";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return res.json({
|
|
291
|
+
success: true,
|
|
292
|
+
mode: "proxy",
|
|
293
|
+
status: upstream.status,
|
|
294
|
+
statusText: upstream.statusText,
|
|
295
|
+
headers: responseHeaders,
|
|
296
|
+
body: responseBody,
|
|
297
|
+
});
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error("[Replay Error]", err.message);
|
|
300
|
+
return res.status(500).json({
|
|
301
|
+
success: false,
|
|
302
|
+
error: "Replay failed: " + err.message,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ─── GET /api/scan/session/status — Check if a session is alive ─────────────
|
|
308
|
+
|
|
309
|
+
router.get("/session/status", (req, res) => {
|
|
310
|
+
const { scanId } = req.query;
|
|
311
|
+
if (!scanId) {
|
|
312
|
+
return res.json({
|
|
313
|
+
success: true,
|
|
314
|
+
sessions: sessionStore.list(),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const engine = sessionStore.get(scanId);
|
|
319
|
+
res.json({
|
|
320
|
+
success: true,
|
|
321
|
+
alive: !!(engine && engine.isAlive),
|
|
322
|
+
scanId,
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ─── DELETE /api/scan/session/:scanId — Close a session ─────────────────────
|
|
327
|
+
|
|
328
|
+
router.delete("/session/:scanId", async (req, res) => {
|
|
329
|
+
const { scanId } = req.params;
|
|
330
|
+
await sessionStore.destroy(scanId);
|
|
331
|
+
res.json({ success: true, message: `Session ${scanId} closed.` });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
module.exports = router;
|
package/server.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const express = require("express");
|
|
2
|
+
const cors = require("cors");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const { PORT } = require("./config");
|
|
6
|
+
const scanRoute = require("./routes/scan");
|
|
7
|
+
|
|
8
|
+
const app = express();
|
|
9
|
+
|
|
10
|
+
const corsOptions = {
|
|
11
|
+
origin: process.env.FRONTEND_URL || "*",
|
|
12
|
+
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
13
|
+
allowedHeaders: ["Content-Type", "Authorization"]
|
|
14
|
+
};
|
|
15
|
+
app.use(cors(corsOptions)); // allow React frontend to call this later
|
|
16
|
+
app.use(express.json());
|
|
17
|
+
|
|
18
|
+
// Routes
|
|
19
|
+
app.get("/health", (req, res) => {
|
|
20
|
+
res.json({ status: "Backend is running" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.get("/ping", (req, res) => {
|
|
24
|
+
res.send("pong");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
app.use("/api/scan", scanRoute);
|
|
28
|
+
|
|
29
|
+
// Serve frontend dist if it exists (for NPX package)
|
|
30
|
+
const distPath = path.join(__dirname, "dist");
|
|
31
|
+
if (fs.existsSync(distPath)) {
|
|
32
|
+
app.use(express.static(distPath));
|
|
33
|
+
app.use((req, res, next) => {
|
|
34
|
+
if (req.method === "GET" && !req.path.startsWith("/api")) {
|
|
35
|
+
res.sendFile(path.join(distPath, "index.html"));
|
|
36
|
+
} else {
|
|
37
|
+
next();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
app.listen(PORT, () => {
|
|
43
|
+
console.log(`[Server] Running on http://localhost:${PORT}`);
|
|
44
|
+
});
|