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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetworkCollector — CDP-based continuous network capture.
|
|
3
|
+
*
|
|
4
|
+
* Uses Chrome DevTools Protocol directly (not Puppeteer's request interception)
|
|
5
|
+
* to capture ALL network traffic including Service Worker requests,
|
|
6
|
+
* background fetches, WebSocket frames, and preflight CORS requests.
|
|
7
|
+
*
|
|
8
|
+
* Emits events: 'request', 'response', 'complete', 'failed'
|
|
9
|
+
* so callers can stream results in real-time (SSE / manual mode).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const EventEmitter = require("events");
|
|
13
|
+
|
|
14
|
+
// ─── Filter Lists ───────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const API_RESOURCE_TYPES = new Set([
|
|
17
|
+
"XHR", "Fetch", "WebSocket", "Other",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const IGNORED_DOMAINS = [
|
|
21
|
+
"google-analytics.com",
|
|
22
|
+
"googletagmanager.com",
|
|
23
|
+
"doubleclick.net",
|
|
24
|
+
"facebook.net",
|
|
25
|
+
"hotjar.com",
|
|
26
|
+
"clarity.ms",
|
|
27
|
+
"cdn.jsdelivr.net",
|
|
28
|
+
"fonts.googleapis.com",
|
|
29
|
+
"fonts.gstatic.com",
|
|
30
|
+
"firebaseinstallations.googleapis.com",
|
|
31
|
+
"firebase.googleapis.com",
|
|
32
|
+
"firebaselogging-pa.googleapis.com",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const IGNORED_EXTENSIONS = new Set([
|
|
36
|
+
".png", ".jpg", ".jpeg", ".gif", ".svg",
|
|
37
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
38
|
+
".ico", ".mp4", ".webp", ".webm",
|
|
39
|
+
".css", ".js", ".map", ".json5",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const ALWAYS_CAPTURE_PATTERNS = [
|
|
43
|
+
"/api/", "/v1/", "/v2/", "/v3/",
|
|
44
|
+
"/graphql", "/rest/", "/rpc/",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const ALWAYS_CAPTURE_DOMAINS = [
|
|
48
|
+
"onrender.com", "herokuapp.com", "vercel.app",
|
|
49
|
+
"railway.app", "supabase.co", "netlify.app",
|
|
50
|
+
"netlify.com", "render.com", "vercel.com",
|
|
51
|
+
"firestore.googleapis.com",
|
|
52
|
+
"identitytoolkit.googleapis.com",
|
|
53
|
+
"securetoken.googleapis.com",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function isIgnoredDomain(url) {
|
|
59
|
+
if (ALWAYS_CAPTURE_DOMAINS.some((d) => url.includes(d))) return false;
|
|
60
|
+
return IGNORED_DOMAINS.some((d) => url.includes(d));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isIgnoredExtension(url) {
|
|
64
|
+
try {
|
|
65
|
+
const pathname = new URL(url).pathname.toLowerCase();
|
|
66
|
+
return [...IGNORED_EXTENSIONS].some((ext) => pathname.endsWith(ext));
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isApiRequest(type, url) {
|
|
73
|
+
if (!API_RESOURCE_TYPES.has(type)) return false;
|
|
74
|
+
if (isIgnoredDomain(url)) return false;
|
|
75
|
+
if (isIgnoredExtension(url)) return false;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── NetworkCollector Class ─────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
class NetworkCollector extends EventEmitter {
|
|
82
|
+
/**
|
|
83
|
+
* @param {import('puppeteer-core').Page} page
|
|
84
|
+
* @param {object} options
|
|
85
|
+
* @param {number} [options.responsePreviewLimit=10000]
|
|
86
|
+
*/
|
|
87
|
+
constructor(page, options = {}) {
|
|
88
|
+
super();
|
|
89
|
+
this.page = page;
|
|
90
|
+
this.cdpClient = null;
|
|
91
|
+
this.capturedMap = new Map();
|
|
92
|
+
this.idCounter = 0;
|
|
93
|
+
this.responsePreviewLimit = options.responsePreviewLimit || 10000;
|
|
94
|
+
this._active = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Start capturing network traffic via CDP. */
|
|
98
|
+
async start() {
|
|
99
|
+
this.cdpClient = await this.page.createCDPSession();
|
|
100
|
+
await this.cdpClient.send("Network.enable");
|
|
101
|
+
this._active = true;
|
|
102
|
+
|
|
103
|
+
// ── Request initiated ──
|
|
104
|
+
this.cdpClient.on("Network.requestWillBeSent", (event) => {
|
|
105
|
+
if (!this._active) return;
|
|
106
|
+
const { requestId, request, type } = event;
|
|
107
|
+
if (!isApiRequest(type, request.url)) return;
|
|
108
|
+
|
|
109
|
+
this.idCounter++;
|
|
110
|
+
const entry = {
|
|
111
|
+
id: this.idCounter,
|
|
112
|
+
url: request.url,
|
|
113
|
+
method: request.method,
|
|
114
|
+
requestHeaders: request.headers,
|
|
115
|
+
payload: request.postData || null,
|
|
116
|
+
type: (type || "other").toLowerCase(),
|
|
117
|
+
status: null,
|
|
118
|
+
responseHeaders: null,
|
|
119
|
+
response: null,
|
|
120
|
+
timestamp: Date.now(),
|
|
121
|
+
_requestId: requestId,
|
|
122
|
+
};
|
|
123
|
+
this.capturedMap.set(requestId, entry);
|
|
124
|
+
this.emit("request", this._clean(entry));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── Response headers received ──
|
|
128
|
+
this.cdpClient.on("Network.responseReceived", (event) => {
|
|
129
|
+
if (!this._active) return;
|
|
130
|
+
const { requestId, response } = event;
|
|
131
|
+
const entry = this.capturedMap.get(requestId);
|
|
132
|
+
if (!entry) return;
|
|
133
|
+
|
|
134
|
+
entry.status = response.status;
|
|
135
|
+
entry.responseHeaders = response.headers;
|
|
136
|
+
this.emit("response", this._clean(entry));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── Response body available ──
|
|
140
|
+
this.cdpClient.on("Network.loadingFinished", async (event) => {
|
|
141
|
+
if (!this._active) return;
|
|
142
|
+
const { requestId } = event;
|
|
143
|
+
const entry = this.capturedMap.get(requestId);
|
|
144
|
+
if (!entry) return;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const { body, base64Encoded } = await this.cdpClient.send(
|
|
148
|
+
"Network.getResponseBody",
|
|
149
|
+
{ requestId }
|
|
150
|
+
);
|
|
151
|
+
entry.response = base64Encoded
|
|
152
|
+
? "[Binary data]"
|
|
153
|
+
: body.slice(0, this.responsePreviewLimit);
|
|
154
|
+
} catch {
|
|
155
|
+
entry.response = "[Could not read response body]";
|
|
156
|
+
}
|
|
157
|
+
this.emit("complete", this._clean(entry));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── Request failed ──
|
|
161
|
+
this.cdpClient.on("Network.loadingFailed", (event) => {
|
|
162
|
+
if (!this._active) return;
|
|
163
|
+
const { requestId, errorText } = event;
|
|
164
|
+
const entry = this.capturedMap.get(requestId);
|
|
165
|
+
if (!entry) return;
|
|
166
|
+
entry.response = `[Request failed: ${errorText}]`;
|
|
167
|
+
this.emit("failed", this._clean(entry));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Stop capturing and detach CDP session. */
|
|
172
|
+
async stop() {
|
|
173
|
+
this._active = false;
|
|
174
|
+
if (this.cdpClient) {
|
|
175
|
+
try { await this.cdpClient.detach(); } catch { /* already detached */ }
|
|
176
|
+
this.cdpClient = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Number of captured API calls. */
|
|
181
|
+
get count() {
|
|
182
|
+
return this.capturedMap.size;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Return all captured requests as a sorted array (cleaned of internals).
|
|
187
|
+
*/
|
|
188
|
+
getResults() {
|
|
189
|
+
return Array.from(this.capturedMap.values())
|
|
190
|
+
.map((e) => this._clean(e))
|
|
191
|
+
.sort((a, b) => a.id - b.id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Wait until no new API requests arrive for `silenceMs`,
|
|
196
|
+
* or until `maxWaitMs` has elapsed — whichever comes first.
|
|
197
|
+
*/
|
|
198
|
+
waitForSilence(silenceMs = 3000, maxWaitMs = 20000) {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
let silenceTimer = null;
|
|
201
|
+
let maxTimer = null;
|
|
202
|
+
|
|
203
|
+
const done = () => {
|
|
204
|
+
if (silenceTimer) clearTimeout(silenceTimer);
|
|
205
|
+
if (maxTimer) clearTimeout(maxTimer);
|
|
206
|
+
this.removeListener("request", resetSilence);
|
|
207
|
+
resolve();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const resetSilence = () => {
|
|
211
|
+
if (silenceTimer) clearTimeout(silenceTimer);
|
|
212
|
+
silenceTimer = setTimeout(done, silenceMs);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
this.on("request", resetSilence);
|
|
216
|
+
maxTimer = setTimeout(done, maxWaitMs);
|
|
217
|
+
resetSilence(); // start initial silence timer
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Wait for at least `count` *new* requests to arrive (from now),
|
|
223
|
+
* or until `timeoutMs` elapses.
|
|
224
|
+
*/
|
|
225
|
+
waitForNewRequests(count = 1, timeoutMs = 10000) {
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
const startCount = this.count;
|
|
228
|
+
let timer = null;
|
|
229
|
+
|
|
230
|
+
const check = () => {
|
|
231
|
+
if (this.count - startCount >= count) {
|
|
232
|
+
clearTimeout(timer);
|
|
233
|
+
this.removeListener("request", check);
|
|
234
|
+
resolve(true);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
this.on("request", check);
|
|
239
|
+
timer = setTimeout(() => {
|
|
240
|
+
this.removeListener("request", check);
|
|
241
|
+
resolve(false);
|
|
242
|
+
}, timeoutMs);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Strip internal fields before exposing to consumers. */
|
|
247
|
+
_clean(entry) {
|
|
248
|
+
const { _requestId, timestamp, ...rest } = entry;
|
|
249
|
+
return rest;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = { NetworkCollector };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStore — Manages active Puppeteer sessions for session-aware replay.
|
|
3
|
+
*
|
|
4
|
+
* Each scan keeps its browser/page alive for a TTL window so replay
|
|
5
|
+
* requests can execute inside the same authenticated browser context
|
|
6
|
+
* (cookies, tokens, service-worker caches, IndexedDB auth state).
|
|
7
|
+
*
|
|
8
|
+
* Sessions auto-expire after SESSION_TTL_MS to avoid resource leaks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
|
|
13
|
+
const SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
14
|
+
|
|
15
|
+
class SessionStore {
|
|
16
|
+
constructor() {
|
|
17
|
+
/** @type {Map<string, { engine: SessionEngine, createdAt: number, timer: NodeJS.Timeout }>} */
|
|
18
|
+
this._sessions = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Store a SessionEngine reference keyed by a new scanId.
|
|
23
|
+
* Returns the generated scanId.
|
|
24
|
+
*
|
|
25
|
+
* @param {import('./interceptor').SessionEngine} engine
|
|
26
|
+
* @returns {string} scanId
|
|
27
|
+
*/
|
|
28
|
+
store(engine) {
|
|
29
|
+
const scanId = crypto.randomUUID();
|
|
30
|
+
|
|
31
|
+
// Auto-expire timer
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
this.destroy(scanId);
|
|
34
|
+
}, SESSION_TTL_MS);
|
|
35
|
+
|
|
36
|
+
this._sessions.set(scanId, {
|
|
37
|
+
engine,
|
|
38
|
+
createdAt: Date.now(),
|
|
39
|
+
timer,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
console.log(`[SessionStore] Stored session ${scanId} (TTL ${SESSION_TTL_MS / 1000}s)`);
|
|
43
|
+
return scanId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Retrieve a session engine by scanId.
|
|
48
|
+
* Returns null if not found or expired.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} scanId
|
|
51
|
+
* @returns {import('./interceptor').SessionEngine | null}
|
|
52
|
+
*/
|
|
53
|
+
get(scanId) {
|
|
54
|
+
const entry = this._sessions.get(scanId);
|
|
55
|
+
if (!entry) return null;
|
|
56
|
+
return entry.engine;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a session exists and is alive.
|
|
61
|
+
* @param {string} scanId
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
has(scanId) {
|
|
65
|
+
return this._sessions.has(scanId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extend the TTL of a session (called after successful replay).
|
|
70
|
+
* @param {string} scanId
|
|
71
|
+
*/
|
|
72
|
+
touch(scanId) {
|
|
73
|
+
const entry = this._sessions.get(scanId);
|
|
74
|
+
if (!entry) return;
|
|
75
|
+
|
|
76
|
+
clearTimeout(entry.timer);
|
|
77
|
+
entry.timer = setTimeout(() => {
|
|
78
|
+
this.destroy(scanId);
|
|
79
|
+
}, SESSION_TTL_MS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Destroy a session: close browser, remove from store.
|
|
84
|
+
* @param {string} scanId
|
|
85
|
+
*/
|
|
86
|
+
async destroy(scanId) {
|
|
87
|
+
const entry = this._sessions.get(scanId);
|
|
88
|
+
if (!entry) return;
|
|
89
|
+
|
|
90
|
+
clearTimeout(entry.timer);
|
|
91
|
+
this._sessions.delete(scanId);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (entry.engine && entry.engine.browser) {
|
|
95
|
+
console.log(`[SessionStore] Closing session ${scanId}`);
|
|
96
|
+
await entry.engine.browser.close();
|
|
97
|
+
entry.engine.browser = null;
|
|
98
|
+
entry.engine.page = null;
|
|
99
|
+
entry.engine._running = false;
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.warn(`[SessionStore] Error closing session ${scanId}:`, err.message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Destroy all sessions.
|
|
108
|
+
*/
|
|
109
|
+
async destroyAll() {
|
|
110
|
+
const ids = [...this._sessions.keys()];
|
|
111
|
+
for (const id of ids) {
|
|
112
|
+
await this.destroy(id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get info about all active sessions.
|
|
118
|
+
*/
|
|
119
|
+
list() {
|
|
120
|
+
const sessions = [];
|
|
121
|
+
for (const [id, entry] of this._sessions) {
|
|
122
|
+
sessions.push({
|
|
123
|
+
scanId: id,
|
|
124
|
+
createdAt: entry.createdAt,
|
|
125
|
+
alive: !!(entry.engine && entry.engine.page),
|
|
126
|
+
ageMs: Date.now() - entry.createdAt,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return sessions;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get size() {
|
|
133
|
+
return this._sessions.size;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Singleton instance
|
|
138
|
+
const sessionStore = new SessionStore();
|
|
139
|
+
|
|
140
|
+
module.exports = { sessionStore, SESSION_TTL_MS };
|