@xbrowser/cli 1.11.0 → 1.12.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/dist/{browser-V3JIWSTR.js → browser-3HQKDZQ6.js} +4 -3
- package/dist/{browser-Q5APBNF6.js → browser-ILXTEWH4.js} +1 -1
- package/dist/{browser-XNU7JQYC.js → browser-XPDMY2NX.js} +2 -2
- package/dist/{cdp-driver-VEK6BNN6.js → cdp-driver-ESH3PDE6.js} +1 -1
- package/dist/{cdp-driver-XFTTZI5O.js → cdp-driver-N2E2ZNSC.js} +1142 -12
- package/dist/{cdp-driver-GD6YBBGE.js → cdp-driver-YIKR4BUX.js} +2 -1
- package/dist/chunk-A6LPGFAL.js +437 -0
- package/dist/{chunk-7OFM755Z.js → chunk-DKM2JOZL.js} +202 -12
- package/dist/{chunk-ABXMBNQ6.js → chunk-H2A5JUK5.js} +37 -466
- package/dist/{chunk-BNO7OKO4.js → chunk-HBMEFSTB.js} +39 -0
- package/dist/chunk-KJTABK3Z.js +1255 -0
- package/dist/{chunk-DWDXEGVK.js → chunk-NKW4A74J.js} +9 -3
- package/dist/{chunk-SQHSZENE.js → chunk-OMU63E6J.js} +3 -22
- package/dist/{chunk-JEDP4PJW.js → chunk-OVW2UEWN.js} +878 -687
- package/dist/{chunk-XKQVUFUS.js → chunk-R6IJ6PIV.js} +2 -2
- package/dist/{chunk-WHPBNUKB.js → chunk-SH6OTPXN.js} +42 -46
- package/dist/{chunk-MWFHZUIY.js → chunk-TEXCXIBW.js} +1 -1
- package/dist/{chunk-XQ4HRPDJ.js → chunk-Z6CUR3VG.js} +1143 -12
- package/dist/{chunk-IIM5GOD7.js → chunk-ZTHE5RBZ.js} +7 -1
- package/dist/cli.js +1075 -569
- package/dist/{daemon-client-MMDRYCF5.js → daemon-client-7D6EZNOE.js} +42 -46
- package/dist/{daemon-client-Y2YSZCGK.js → daemon-client-HEGAXWGK.js} +1 -1
- package/dist/daemon-main.js +950 -499
- package/dist/{human-interaction-5AO42MBA.js → human-interaction-4YR2N6R6.js} +2 -2
- package/dist/{human-interaction-C5OEGXGO.js → human-interaction-Y5IDH6LD.js} +1 -1
- package/dist/{human-interaction-ISXZTAYY.js → human-interaction-ZUTR5AC2.js} +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1086 -578
- package/dist/{proxy-C6CK3UH5.js → proxy-LUR4U5YF.js} +2 -1
- package/dist/{recovery-J2ISVGUL.js → recovery-FTZGV6VY.js} +2 -2
- package/dist/{recovery-ZJVVHP7N.js → recovery-L5GLDX4O.js} +1 -1
- package/dist/{recovery-EF33LKRJ.js → recovery-Z3RDZENA.js} +2 -2
- package/dist/{session-replayer-WIUYVN5J.js → session-replayer-K4A6OI4M.js} +1 -1
- package/package.json +1 -1
- package/dist/chunk-HVPUVVSA.js +0 -2194
|
@@ -130,8 +130,8 @@ function buildStealthInitScript() {
|
|
|
130
130
|
" if(!e||e.constructor===FocusEvent||e.constructor===KeyboardEvent)return f.call(this,e);",
|
|
131
131
|
" return f.call(this,new Proxy(e,{get:function(k,p){",
|
|
132
132
|
' if(p==="sourceCapabilities")return fc;',
|
|
133
|
-
' if(p==="isTrusted")return
|
|
134
|
-
' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.
|
|
133
|
+
' if(p==="isTrusted")return k.isTrusted;',
|
|
134
|
+
' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.isInteger(k[p])&&k.isTrusted===true){',
|
|
135
135
|
" var _f=((k.timeStamp||Date.now())%89)/89*0.7+0.15;",
|
|
136
136
|
" return k[p]+_f;",
|
|
137
137
|
" }",
|
|
@@ -150,6 +150,126 @@ function buildStealthInitScript() {
|
|
|
150
150
|
' Object.defineProperty(Screen.prototype,"availWidth",{get:_gw,configurable:true});',
|
|
151
151
|
' Object.defineProperty(Screen.prototype,"availHeight",{get:_gah,configurable:true});',
|
|
152
152
|
" document.hasFocus=function(){return true};",
|
|
153
|
+
// 4. Canvas/WebGL fingerprint: per-session stable noise (d20).
|
|
154
|
+
// Headless software raster differs subtly from Chrome GPU raster —
|
|
155
|
+
// toDataURL hashes fingerprint the rasterizer. Inject a stable
|
|
156
|
+
// (per-page-load) subpixel shift into fillText so hashes look like a
|
|
157
|
+
// distinct-but-consistent real GPU, and hide the HEADLESS tell in
|
|
158
|
+
// WebGL renderer strings.
|
|
159
|
+
" var _seed=Math.floor(Math.random()*2147483647);",
|
|
160
|
+
" var _prng=function(){_seed=(_seed*48271)%2147483647;return _seed/2147483647;};",
|
|
161
|
+
" var _dx=(_prng()*0.4-0.2).toFixed(3)*1, _dy=(_prng()*0.4-0.2).toFixed(3)*1;",
|
|
162
|
+
" var _fillText=CanvasRenderingContext2D.prototype.fillText;",
|
|
163
|
+
" CanvasRenderingContext2D.prototype.fillText=function(t,x,y,m){",
|
|
164
|
+
" return _fillText.call(this,t,x+_dx,y+_dy,m);",
|
|
165
|
+
" };",
|
|
166
|
+
" var _toDataURL=HTMLCanvasElement.prototype.toDataURL;",
|
|
167
|
+
" HTMLCanvasElement.prototype.toDataURL=function(){",
|
|
168
|
+
' var ctx=this.getContext("2d");',
|
|
169
|
+
" if(ctx){var d=ctx.getImageData(0,0,Math.min(this.width,2),Math.min(this.height,2));",
|
|
170
|
+
" for(var i=0;i<d.data.length;i+=4){if(d.data[i+3]>0){d.data[i]^=1;break;}}",
|
|
171
|
+
" ctx.putImageData(d,0,0);}",
|
|
172
|
+
" return _toDataURL.apply(this,arguments);",
|
|
173
|
+
" };",
|
|
174
|
+
" try{",
|
|
175
|
+
" var _gl=HTMLCanvasElement.prototype.getContext;",
|
|
176
|
+
" HTMLCanvasElement.prototype.getContext=function(t,o){",
|
|
177
|
+
" var c=_gl.call(this,t,o);",
|
|
178
|
+
' if(c&&(t==="webgl"||t==="experimental-webgl")&&c.getParameter){',
|
|
179
|
+
" var _gp=c.getParameter.bind(c);",
|
|
180
|
+
" c.getParameter=function(p){",
|
|
181
|
+
" var v=_gp(p);",
|
|
182
|
+
' if(typeof v==="string"&&/SwiftShader|Software|Rasterizer|Headless/i.test(v))',
|
|
183
|
+
' return "ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max, Unspecified Version)";',
|
|
184
|
+
" return v;};}",
|
|
185
|
+
" return c;};",
|
|
186
|
+
" }catch(e){}",
|
|
187
|
+
// 5. AudioContext fingerprint: per-load stable micro-noise on channel
|
|
188
|
+
// data (d21). DSP sum differences between headless software audio and
|
|
189
|
+
// real hardware audio are a classic fingerprint — add ±1e-7 level
|
|
190
|
+
// noise (inaudible, changes the sum hash).
|
|
191
|
+
" try{",
|
|
192
|
+
" var _gcd=AudioBuffer.prototype.getChannelData;",
|
|
193
|
+
" AudioBuffer.prototype.getChannelData=function(ch){",
|
|
194
|
+
" var d=_gcd.call(this,ch);",
|
|
195
|
+
' var key="__xb_audio_"+ch;',
|
|
196
|
+
" if(!this[key]){",
|
|
197
|
+
" this[key]=true;",
|
|
198
|
+
" for(var i=0;i<d.length;i+=997){d[i]=d[i]+(_prng()-0.5)*2e-7;}",
|
|
199
|
+
" }",
|
|
200
|
+
" return d;",
|
|
201
|
+
" };",
|
|
202
|
+
" var _gffd=AnalyserNode.prototype.getFloatFrequencyData;",
|
|
203
|
+
" AnalyserNode.prototype.getFloatFrequencyData=function(arr){",
|
|
204
|
+
" _gffd.call(this,arr);",
|
|
205
|
+
" for(var i=0;i<arr.length;i+=31){arr[i]=arr[i]+(_prng()-0.5)*0.01;}",
|
|
206
|
+
" };",
|
|
207
|
+
" var _gfbd=AnalyserNode.prototype.getByteFrequencyData;",
|
|
208
|
+
" AnalyserNode.prototype.getByteFrequencyData=function(arr){",
|
|
209
|
+
" _gfbd.call(this,arr);",
|
|
210
|
+
" for(var i=0;i<arr.length;i+=31){arr[i]=(arr[i]+((_prng()*3)|0))&255;}",
|
|
211
|
+
" };",
|
|
212
|
+
" }catch(e){}",
|
|
213
|
+
// 3b. Font metrics + speechSynthesis + battery (d22): headless tells
|
|
214
|
+
" try{",
|
|
215
|
+
" var _mt=CanvasRenderingContext2D.prototype.measureText;",
|
|
216
|
+
' var _tmw=Object.getOwnPropertyDescriptor(TextMetrics.prototype,"width");',
|
|
217
|
+
" CanvasRenderingContext2D.prototype.measureText=function(t){",
|
|
218
|
+
" var m=_mt.call(this,t);",
|
|
219
|
+
' try{Object.defineProperty(m,"width",{get:function(){return _tmw.get.call(m)+_dx*0.01;},configurable:true});}catch(e){}',
|
|
220
|
+
" return m;",
|
|
221
|
+
" };",
|
|
222
|
+
" }catch(e){}",
|
|
223
|
+
" try{",
|
|
224
|
+
' var _fakeVoices=["Alex","Daniel","Karen","Moira","Ralph","Samantha","Ting-Ting","Mei-Jia","Sinji","Yunda"];',
|
|
225
|
+
" var _sv=speechSynthesis.getVoices.bind(speechSynthesis);",
|
|
226
|
+
" speechSynthesis.getVoices=function(){",
|
|
227
|
+
" var real=_sv();",
|
|
228
|
+
" if(real&&real.length)return real;",
|
|
229
|
+
' return _fakeVoices.map(function(n,i){return {name:n,lang:i<2?"en-US":i<6?"en-GB":"zh-TW",localService:true,default:i===0,voiceURI:n};});',
|
|
230
|
+
" };",
|
|
231
|
+
" }catch(e){}",
|
|
232
|
+
" try{",
|
|
233
|
+
" var _gb=navigator.getBattery.bind(navigator);",
|
|
234
|
+
" navigator.getBattery=function(){",
|
|
235
|
+
" return _gb().then(function(b){",
|
|
236
|
+
' Object.defineProperty(b,"charging",{get:function(){return true;},configurable:true});',
|
|
237
|
+
" return b;});",
|
|
238
|
+
" };",
|
|
239
|
+
" }catch(e){}",
|
|
240
|
+
// 3c. WebRTC local IP leak guard (d23): headless STUN candidates can
|
|
241
|
+
// expose host IPs; mDNS-only candidates are the modern Chrome default.
|
|
242
|
+
" try{",
|
|
243
|
+
" var _oc=RTCPeerConnection.prototype.createOffer;",
|
|
244
|
+
" RTCPeerConnection.prototype.createOffer=function(){",
|
|
245
|
+
" var p=_oc.apply(this,arguments);",
|
|
246
|
+
" var self=this;",
|
|
247
|
+
" return p.then(function(offer){",
|
|
248
|
+
' offer.sdp=offer.sdp.split(String.fromCharCode(10)).filter(function(l){return l.indexOf("typ host")<0}).join(String.fromCharCode(10));',
|
|
249
|
+
" return offer;});",
|
|
250
|
+
" };",
|
|
251
|
+
" }catch(e){}",
|
|
252
|
+
// 3d. Chrome object depth (d24): automation fakes usually only set
|
|
253
|
+
// window.chrome = {}; deep checks hit app.run/runtime/csi/loadTimes.
|
|
254
|
+
" try{",
|
|
255
|
+
" if(window.chrome){",
|
|
256
|
+
' if(!window.chrome.app||!window.chrome.app.run)window.chrome.app={run:function(){},load:function(){},getDetails:function(){return null},InstallState:{DISABLED:"disabled",INSTALLED:"installed",NOT_INSTALLED:"not_installed"},RunningState:{CANNOT_RUN:"cannot_run",READY_TO_RUN:"ready_to_run",RUNNING:"running"}};',
|
|
257
|
+
' if(!window.chrome.runtime)window.chrome.runtime={OnInstalledReason:{CHROME_UPDATE:"chrome_update",INSTALL:"install",UPDATE:"update"},PlatformOs:{ANDROID:"android",CROS:"cros",LINUX:"linux",MAC:"mac",OPENBSD:"openbsd",WIN:"win"},connect:function(){},sendMessage:function(){},id:undefined};',
|
|
258
|
+
" if(!window.chrome.csi)window.chrome.csi=function(){return{};}",
|
|
259
|
+
" if(!window.chrome.loadTimes)window.chrome.loadTimes=function(){return{};}",
|
|
260
|
+
" }",
|
|
261
|
+
" }catch(e){}",
|
|
262
|
+
// 3e. Font availability patch (d25): headless Chromium misses some system
|
|
263
|
+
// fonts (e.g. Menlo on macOS) that real Chrome has. Register a FontFace
|
|
264
|
+
// aliasing the missing font to a local() equivalent so offsetWidth-based
|
|
265
|
+
// font probing sees the same availability as the faked environment.
|
|
266
|
+
" try{",
|
|
267
|
+
' var _fontAliases=[["Menlo","Courier New"],["SF Mono","Menlo"],["Segoe UI","Helvetica"]];',
|
|
268
|
+
" _fontAliases.forEach(function(pa){",
|
|
269
|
+
' try{var ff=new FontFace(pa[0],"local("+JSON.stringify(pa[1])+")");',
|
|
270
|
+
" document.fonts.add(ff);ff.load();}catch(e2){}",
|
|
271
|
+
" });",
|
|
272
|
+
" }catch(e){}",
|
|
153
273
|
// 3. toString disguise (name-list based)
|
|
154
274
|
" var _ts=Function.prototype.toString;",
|
|
155
275
|
" var _hf=document.hasFocus;",
|
|
@@ -159,12 +279,16 @@ function buildStealthInitScript() {
|
|
|
159
279
|
' if(this===_gw)return"function get width() { [native code] }";',
|
|
160
280
|
' if(this===_gh)return"function get height() { [native code] }";',
|
|
161
281
|
' if(this===_gah)return"function get availHeight() { [native code] }";',
|
|
282
|
+
' if(this===CanvasRenderingContext2D.prototype.fillText)return"function fillText() { [native code] }";',
|
|
283
|
+
' if(this===HTMLCanvasElement.prototype.toDataURL)return"function toDataURL() { [native code] }";',
|
|
284
|
+
' if(this===AnalyserNode.prototype.getFloatFrequencyData)return"function getFloatFrequencyData() { [native code] }";',
|
|
285
|
+
' if(this===AudioBuffer.prototype.getChannelData)return"function getChannelData() { [native code] }";',
|
|
162
286
|
" return _ts.call(this);",
|
|
163
287
|
" };",
|
|
164
288
|
// 4. onclick prototype hijack (dual-stream consistency)
|
|
165
289
|
" var _ba=function(k,p){",
|
|
166
|
-
' if(p==="isTrusted")return
|
|
167
|
-
' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.
|
|
290
|
+
' if(p==="isTrusted")return k.isTrusted;',
|
|
291
|
+
' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.isInteger(k[p])&&k.isTrusted===true){',
|
|
168
292
|
" var _f=((k.timeStamp||Date.now())%89)/89*0.7+0.15;return k[p]+_f;",
|
|
169
293
|
" }",
|
|
170
294
|
' var v=Reflect.get(k,p);return typeof v==="function"?v.bind(k):v;',
|
|
@@ -200,7 +324,7 @@ var XBMouseImpl = class {
|
|
|
200
324
|
}
|
|
201
325
|
async click(x, y, opts = {}) {
|
|
202
326
|
const button = opts.button ?? "left";
|
|
203
|
-
const stealth = opts.stealth ??
|
|
327
|
+
const stealth = opts.stealth ?? process.env.XBROWSER_STEALTH !== "off";
|
|
204
328
|
let tx = x, ty = y;
|
|
205
329
|
if (stealth && opts.elementWidth !== void 0 && opts.elementHeight !== void 0) {
|
|
206
330
|
const off = landingOffset(opts.elementWidth, opts.elementHeight);
|
|
@@ -209,12 +333,23 @@ var XBMouseImpl = class {
|
|
|
209
333
|
}
|
|
210
334
|
if (stealth) {
|
|
211
335
|
const traj = bezierTrajectory(this._x, this._y, tx, ty);
|
|
336
|
+
const _tb = Date.now();
|
|
337
|
+
let _truncated = false;
|
|
212
338
|
for (const p of traj) {
|
|
339
|
+
if (Date.now() - _tb > 5e3) {
|
|
340
|
+
_truncated = true;
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
213
343
|
await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: p.x, y: p.y, button: this._button });
|
|
214
344
|
this._x = p.x;
|
|
215
345
|
this._y = p.y;
|
|
216
346
|
await sleep(p.delay);
|
|
217
347
|
}
|
|
348
|
+
this._x = tx;
|
|
349
|
+
this._y = ty;
|
|
350
|
+
if (_truncated) {
|
|
351
|
+
await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: tx, y: ty, button: this._button });
|
|
352
|
+
}
|
|
218
353
|
await sleep(rand(...DEFAULT_STEALTH_CONFIG.aimPause));
|
|
219
354
|
} else {
|
|
220
355
|
await this.move(tx, ty);
|
|
@@ -319,12 +454,20 @@ var XBKeyboardImpl = class {
|
|
|
319
454
|
}
|
|
320
455
|
await this.dispatchKeyEvent(downParams);
|
|
321
456
|
if (mapping.text) {
|
|
457
|
+
if (process.env.XBROWSER_STEALTH !== "off") {
|
|
458
|
+
await sleep2(rand(...DEFAULT_STEALTH_CONFIG.keyPressDuration));
|
|
459
|
+
}
|
|
322
460
|
await this.dispatchKeyEvent({
|
|
323
461
|
type: "char",
|
|
324
462
|
text: mapping.text
|
|
325
463
|
});
|
|
326
464
|
}
|
|
327
465
|
if (delay > 0) await sleep2(delay);
|
|
466
|
+
else {
|
|
467
|
+
if (process.env.XBROWSER_STEALTH !== "off") {
|
|
468
|
+
await sleep2(rand(...DEFAULT_STEALTH_CONFIG.keyPressDuration));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
328
471
|
const upParams = {
|
|
329
472
|
type: "keyUp",
|
|
330
473
|
key: mapping.key,
|
|
@@ -2034,6 +2177,10 @@ Last error: ${lastError.message}` : "";
|
|
|
2034
2177
|
this.sessionId
|
|
2035
2178
|
);
|
|
2036
2179
|
}
|
|
2180
|
+
/** 覆盖 User-Agent(headless 环境对抗 UA 检测的标配能力) */
|
|
2181
|
+
async setUserAgent(userAgent) {
|
|
2182
|
+
await this._setUserAgent(userAgent);
|
|
2183
|
+
}
|
|
2037
2184
|
/** Internal: set extra HTTP headers */
|
|
2038
2185
|
async _setExtraHTTPHeaders(headers) {
|
|
2039
2186
|
await this.setExtraHTTPHeaders(headers);
|
|
@@ -3163,534 +3310,259 @@ var XBBrowserImpl = class {
|
|
|
3163
3310
|
// src/cdp-driver/connection.ts
|
|
3164
3311
|
import { EventEmitter as EventEmitter4 } from "events";
|
|
3165
3312
|
import { WebSocket } from "ws";
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3313
|
+
|
|
3314
|
+
// src/cdp-interceptor/rules/shared.ts
|
|
3315
|
+
var PLAYWRIGHT_INTERNAL_MARKERS = [
|
|
3316
|
+
"__commonJS",
|
|
3317
|
+
"module.exports",
|
|
3318
|
+
"__require",
|
|
3319
|
+
"__toESM",
|
|
3320
|
+
"inject_utils"
|
|
3321
|
+
];
|
|
3322
|
+
function isPlaywrightInternal(code) {
|
|
3323
|
+
return PLAYWRIGHT_INTERNAL_MARKERS.some((marker) => code.includes(marker));
|
|
3324
|
+
}
|
|
3325
|
+
function extractUserCode(ctx) {
|
|
3326
|
+
if (ctx.method === "Runtime.evaluate") {
|
|
3327
|
+
const expr = ctx.params.expression;
|
|
3328
|
+
if (typeof expr === "string") {
|
|
3329
|
+
if (isPlaywrightInternal(expr)) return null;
|
|
3330
|
+
return expr;
|
|
3183
3331
|
}
|
|
3184
|
-
this.bindWebSocket();
|
|
3185
|
-
this.startKeepalive();
|
|
3186
3332
|
}
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
this.ws.on("pong", () => {
|
|
3194
|
-
if (this.pongTimer) {
|
|
3195
|
-
clearTimeout(this.pongTimer);
|
|
3196
|
-
this.pongTimer = null;
|
|
3197
|
-
}
|
|
3198
|
-
});
|
|
3199
|
-
this.keepaliveTimer = setInterval(() => {
|
|
3200
|
-
if (this.ws.readyState === WebSocket.OPEN) {
|
|
3201
|
-
if (!this.pongTimer) {
|
|
3202
|
-
this.pongTimer = setTimeout(() => {
|
|
3203
|
-
if (!this.closed) {
|
|
3204
|
-
this.closed = true;
|
|
3205
|
-
this.closeReason = "keepalive timeout (no pong in 10s)";
|
|
3206
|
-
try {
|
|
3207
|
-
this.ws.terminate();
|
|
3208
|
-
} catch {
|
|
3209
|
-
}
|
|
3210
|
-
for (const [, pending] of this.pending) {
|
|
3211
|
-
clearTimeout(pending.timeout);
|
|
3212
|
-
pending.reject(new Error("Connection dead: keepalive timeout"));
|
|
3213
|
-
}
|
|
3214
|
-
this.pending.clear();
|
|
3215
|
-
this.emit("disconnect");
|
|
3216
|
-
}
|
|
3217
|
-
}, 1e4);
|
|
3218
|
-
}
|
|
3219
|
-
this.ws.ping?.();
|
|
3220
|
-
} else if (this.closed) {
|
|
3221
|
-
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
|
|
3222
|
-
this.keepaliveTimer = null;
|
|
3223
|
-
}
|
|
3224
|
-
}, 3e4);
|
|
3333
|
+
if (ctx.method === "Runtime.callFunctionOn") {
|
|
3334
|
+
const decl = ctx.params.functionDeclaration;
|
|
3335
|
+
if (typeof decl === "string" && decl.includes("utilityScript.evaluate")) {
|
|
3336
|
+
return extractAllStrings(ctx.params.arguments);
|
|
3337
|
+
}
|
|
3338
|
+
if (typeof decl === "string") return decl;
|
|
3225
3339
|
}
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3340
|
+
return null;
|
|
3341
|
+
}
|
|
3342
|
+
function extractAllStrings(rawArgs) {
|
|
3343
|
+
if (!Array.isArray(rawArgs)) return null;
|
|
3344
|
+
const strings = [];
|
|
3345
|
+
for (const arg of rawArgs) {
|
|
3346
|
+
if (arg && typeof arg === "object" && "value" in arg) {
|
|
3347
|
+
const val = arg.value;
|
|
3348
|
+
if (typeof val === "string" && val.length > 5 && !["true", "false"].includes(val)) {
|
|
3349
|
+
strings.push(val);
|
|
3350
|
+
}
|
|
3231
3351
|
}
|
|
3232
|
-
return new Promise((resolve, reject) => {
|
|
3233
|
-
const onOpen = () => {
|
|
3234
|
-
this.ws.off("error", onError);
|
|
3235
|
-
resolve();
|
|
3236
|
-
};
|
|
3237
|
-
const onError = (err) => {
|
|
3238
|
-
this.ws.off("open", onOpen);
|
|
3239
|
-
reject(err);
|
|
3240
|
-
};
|
|
3241
|
-
this.ws.once("open", onOpen);
|
|
3242
|
-
this.ws.once("error", onError);
|
|
3243
|
-
});
|
|
3244
3352
|
}
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3353
|
+
return strings.length > 0 ? strings.join("\n") : null;
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
// src/cdp-interceptor/rules/dom-mutation.ts
|
|
3357
|
+
var DOM_PATTERNS = [
|
|
3358
|
+
// ── P0: Value/Checked (bypasses React onChange) ────────────
|
|
3359
|
+
{ pattern: /\.value\s*=\s*(?!["'\s]*$)/, name: ".value =", severity: "danger", action: "pass", errorCode: -32001, suggestion: "Use page.fill(selector, value) which dispatches proper input/change events." },
|
|
3360
|
+
{ pattern: /\.checked\s*=\s*(?:true|false)/, name: ".checked =", severity: "danger", action: "pass", errorCode: -32001, suggestion: "Use page.check(selector) or page.uncheck(selector)." },
|
|
3361
|
+
{ pattern: /\.indeterminate\s*=\s*true/, name: ".indeterminate =", severity: "warn", action: "pass", errorCode: -32021, suggestion: "No human can set indeterminate state \u2014 remove this call." },
|
|
3362
|
+
{ pattern: /\.valueAsDate\s*=/, name: ".valueAsDate =", severity: "warn", action: "pass", errorCode: -32022, suggestion: "Use page.fill() with formatted date string instead." },
|
|
3363
|
+
{ pattern: /\.valueAsNumber\s*=/, name: ".valueAsNumber =", severity: "warn", action: "pass", errorCode: -32022, suggestion: "Use page.fill() with numeric string instead." },
|
|
3364
|
+
// ── P1: Select/Option (bypasses React onChange on select) ─
|
|
3365
|
+
{ pattern: /\.selectedIndex\s*=\s*\d+/, name: ".selectedIndex =", severity: "danger", action: "pass", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
|
|
3366
|
+
{ pattern: /(?:options|children)\s*\[[^\]]*\]\s*\.\s*selected\s*=\s*(?:true|false)/, name: "options[N].selected =", severity: "danger", action: "pass", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
|
|
3367
|
+
{ pattern: /\.value\s*=\s*["'][^"']*["']\s*[;)]?\s*$/, name: "selectElement.value =", severity: "warn", action: "pass", errorCode: -32023, suggestion: "For select elements, use page.selectOption(selector, value)." },
|
|
3368
|
+
// ── P2: Content properties (bypasses virtual DOM diffing) ──
|
|
3369
|
+
{ pattern: /\.innerHTML\s*=/, name: ".innerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: ".innerHTML bypasses React/Vue diffing. Use component state or page.setContent()." },
|
|
3370
|
+
{ pattern: /\.outerHTML\s*=/, name: ".outerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: "outerHTML replacement destroys React fiber tree. Use page.setContent()." },
|
|
3371
|
+
{ pattern: /\.innerText\s*=/, name: ".innerText =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "Framework components should be updated via state, not innerText." },
|
|
3372
|
+
{ pattern: /\.textContent\s*=/, name: ".textContent =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "textContent bypasses React/Vue diffing. Use component state instead." },
|
|
3373
|
+
{ pattern: /\.outerText\s*=/, name: ".outerText =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "Non-standard property \u2014 use proper framework update methods." },
|
|
3374
|
+
{ pattern: /nodeValue\s*=/, name: "node.nodeValue =", severity: "info", action: "pass", errorCode: -32025, suggestion: "Direct text node mutation. Use textContent instead if needed." },
|
|
3375
|
+
// ── P3: Style properties ──────────────────────────────────
|
|
3376
|
+
{ pattern: /\.style\s*=\s*["']/, name: ".style = (string override)", severity: "info", action: "pass", errorCode: -32008, suggestion: 'Setting style as a string overwrites CSSStyleDeclaration. Use element.style.prop = "value".' },
|
|
3377
|
+
{ pattern: /\.style\.cssText\s*=/, name: ".style.cssText =", severity: "info", action: "pass", errorCode: -32008, suggestion: "style.cssText replacement destroys inline styles \u2014 use individual property set." },
|
|
3378
|
+
{ pattern: /\.style\.setProperty\s*\(/, name: ".style.setProperty()", severity: "info", action: "pass", errorCode: -32008, suggestion: "Direct style property set bypasses CSS transitions." },
|
|
3379
|
+
{ pattern: /\.style\.removeProperty\s*\(/, name: ".style.removeProperty()", severity: "info", action: "pass", errorCode: -32026, suggestion: "Style removal without user interaction is suspicious." },
|
|
3380
|
+
{ pattern: /\.style\.animation\s*=/, name: ".style.animation =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Forcing animation state via CDP is detectable." },
|
|
3381
|
+
{ pattern: /\.style\.transition\s*=/, name: ".style.transition =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Manipulating CSS transitions is rare in normal browsing." },
|
|
3382
|
+
// ── P4: Class/Attribute properties ────────────────────────
|
|
3383
|
+
{ pattern: /\.className\s*=/, name: ".className =", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or a Playwright locator instead." },
|
|
3384
|
+
{ pattern: /\.classList\.add\s*\(/, name: ".classList.add()", severity: "info", action: "pass", errorCode: -32009, suggestion: "classList manipulation via CDP bypasses framework state tracking." },
|
|
3385
|
+
{ pattern: /\.classList\.remove\s*\(/, name: ".classList.remove()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or attribute selectors instead." },
|
|
3386
|
+
{ pattern: /\.classList\.toggle\s*\(/, name: ".classList.toggle()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Toggle without user interaction is detectable." },
|
|
3387
|
+
{ pattern: /\.classList\.replace\s*\(/, name: ".classList.replace()", severity: "info", action: "pass", errorCode: -32028, suggestion: "Rare operation \u2014 likely automated." },
|
|
3388
|
+
// ── P5: Attribute manipulation ────────────────────────────
|
|
3389
|
+
{ pattern: /\.setAttribute\s*\(/, name: ".setAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "setAttribute bypasses framework attribute tracking. Use component state." },
|
|
3390
|
+
{ pattern: /\.removeAttribute\s*\(/, name: ".removeAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "Attribute removal without user interaction is suspicious." },
|
|
3391
|
+
{ pattern: /\.toggleAttribute\s*\(/, name: ".toggleAttribute()", severity: "info", action: "pass", errorCode: -32029, suggestion: "Attribute toggling via CDP is detectable." },
|
|
3392
|
+
{ pattern: /\.dataset\.\w+\s*=/, name: ".dataset.* =", severity: "info", action: "pass", errorCode: -32030, suggestion: "dataset mutations via evaluate bypass native mutation observers." },
|
|
3393
|
+
// ── P6: Focus/Selection properties ────────────────────────
|
|
3394
|
+
{ pattern: /\.selectionStart\s*=/, name: ".selectionStart =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Setting cursor position without focus is detectable." },
|
|
3395
|
+
{ pattern: /\.selectionEnd\s*=/, name: ".selectionEnd =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Selection range manipulation without user input is suspicious." },
|
|
3396
|
+
{ pattern: /\.selectionDirection\s*=/, name: ".selectionDirection =", severity: "info", action: "pass", errorCode: -32031, suggestion: "Selection direction changes normally via mouse drag." },
|
|
3397
|
+
// ── P7: Boolean properties ────────────────────────────────
|
|
3398
|
+
{ pattern: /\.disabled\s*=/, name: ".disabled =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Disabling elements via CDP mid-interaction is detectable." },
|
|
3399
|
+
{ pattern: /\.readOnly\s*=/, name: ".readOnly =", severity: "info", action: "pass", errorCode: -32032, suggestion: "readOnly changes without user action." },
|
|
3400
|
+
{ pattern: /\.hidden\s*=/, name: ".hidden =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Hiding elements is a common scraper tactic \u2014 detectable." },
|
|
3401
|
+
{ pattern: /\.required\s*=/, name: ".required =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Validation constraint changes mid-session." },
|
|
3402
|
+
{ pattern: /\.multiple\s*=/, name: ".multiple =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Multiple attribute toggle is rare in normal browsing." },
|
|
3403
|
+
{ pattern: /\.autofocus\s*=/, name: ".autofocus =", severity: "info", action: "pass", errorCode: -32032, suggestion: "autofocus changes mid-session are suspicious." },
|
|
3404
|
+
// ── P8: Frame/Navigation properties ───────────────────────
|
|
3405
|
+
{ pattern: /\.src\s*=/, name: ".src = (on img/iframe/script)", severity: "info", action: "pass", errorCode: -32013, suggestion: "Changing src via CDP bypasses user interaction. Use page.click() on the element." },
|
|
3406
|
+
{ pattern: /\.href\s*=/, name: ".href =", severity: "info", action: "pass", errorCode: -32033, suggestion: "Changing anchor href via evaluate \u2014 use page.click() instead." },
|
|
3407
|
+
{ pattern: /\.action\s*=/, name: "form.action =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Changing form action URL is highly suspicious." },
|
|
3408
|
+
{ pattern: /\.method\s*=/, name: "form.method =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Form method changes without user interaction." },
|
|
3409
|
+
{ pattern: /\.target\s*=/, name: "link/area.target =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Link target manipulation via CDP." },
|
|
3410
|
+
// ── P9: Media properties ──────────────────────────────────
|
|
3411
|
+
{ pattern: /\.currentTime\s*=/, name: "media.currentTime = (seeking)", severity: "info", action: "pass", errorCode: -32014, suggestion: "Video seeking without user interaction \u2014 common scraper pattern." },
|
|
3412
|
+
{ pattern: /\.playbackRate\s*=/, name: "media.playbackRate =", severity: "info", action: "pass", errorCode: -32014, suggestion: "Changing playback speed is detectable automation signal." },
|
|
3413
|
+
{ pattern: /\.volume\s*=/, name: "media.volume =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Volume setting via evaluate is suspicious." },
|
|
3414
|
+
{ pattern: /\.muted\s*=/, name: "media.muted =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Muting media without user click is suspicious." },
|
|
3415
|
+
// ── P10: Element geometry ─────────────────────────────────
|
|
3416
|
+
{ pattern: /\.scrollTop\s*=/, name: ".scrollTop =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Programmatic scroll without user gesture. Use page.mouse.wheel()." },
|
|
3417
|
+
{ pattern: /\.scrollLeft\s*=/, name: ".scrollLeft =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Horizontal scroll without user gesture." },
|
|
3418
|
+
{ pattern: /\.scrollTo\s*\(/, name: ".scrollTo()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollTo bypasses user scroll detection." },
|
|
3419
|
+
{ pattern: /\.scrollBy\s*\(/, name: ".scrollBy()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollBy without user gesture." },
|
|
3420
|
+
{ pattern: /\.scrollIntoView\s*\(/, name: ".scrollIntoView()", severity: "info", action: "pass", errorCode: -32015, suggestion: "scrollIntoView is a common bot pattern. Let Playwright handle scrolling." },
|
|
3421
|
+
// ── P11: Shadow DOM ───────────────────────────────────────
|
|
3422
|
+
{ pattern: /\.shadowRoot\s*=/, name: ".shadowRoot = (override)", severity: "info", action: "pass", errorCode: -32036, suggestion: "ShadowRoot is read-only \u2014 this set attempt is detectable." },
|
|
3423
|
+
// ── P12: Force reflow / layout thrashing ──────────────────
|
|
3424
|
+
{ pattern: /\.offsetHeight\b(?!\s*===?\s*)/, name: ".offsetHeight read (forced reflow)", severity: "warn", action: "pass", errorCode: -32016, suggestion: "Reading offsetHeight triggers forced reflow \u2014 anti-crawlers detect this as layout probing." },
|
|
3425
|
+
{ pattern: /\.offsetWidth\b(?!\s*===?\s*)/, name: ".offsetWidth read (forced reflow)", severity: "warn", action: "pass", errorCode: -32016, suggestion: "Reading offsetWidth triggers forced reflow \u2014 detectable." },
|
|
3426
|
+
{ pattern: /getBoundingClientRect\s*\(/, name: "getBoundingClientRect()", severity: "warn", action: "pass", errorCode: -32037, suggestion: "getBoundingClientRect triggers reflow. Minimize calls." },
|
|
3427
|
+
{ pattern: /getComputedStyle\s*\(/, name: "getComputedStyle()", severity: "info", action: "pass", errorCode: -32038, suggestion: "getComputedStyle can trigger style recalculation." },
|
|
3428
|
+
{ pattern: /\.clientHeight\b/, name: ".clientHeight read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientHeight read triggers layout." },
|
|
3429
|
+
{ pattern: /\.clientWidth\b/, name: ".clientWidth read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientWidth read triggers layout." }
|
|
3430
|
+
];
|
|
3431
|
+
var domMutationRule = {
|
|
3432
|
+
id: "dom-mutation",
|
|
3433
|
+
name: "DOM Property Mutation Detection (50+ setters)",
|
|
3434
|
+
priority: 10,
|
|
3435
|
+
canHandle(ctx) {
|
|
3436
|
+
return ctx.method === "Runtime.evaluate" || ctx.method === "Runtime.callFunctionOn";
|
|
3437
|
+
},
|
|
3438
|
+
evaluate(ctx) {
|
|
3439
|
+
const userCode = extractUserCode(ctx);
|
|
3440
|
+
if (!userCode) return null;
|
|
3441
|
+
for (const p of DOM_PATTERNS) {
|
|
3442
|
+
if (p.pattern.test(userCode)) {
|
|
3443
|
+
return {
|
|
3444
|
+
ruleId: "dom-mutation",
|
|
3445
|
+
action: p.action,
|
|
3446
|
+
severity: p.severity,
|
|
3447
|
+
reason: `Direct DOM property setter: "${p.name}". This bypasses framework event systems and is detectable as automation.`,
|
|
3448
|
+
suggestion: p.suggestion,
|
|
3449
|
+
errorCode: p.errorCode,
|
|
3450
|
+
errorMessage: p.severity === "danger" ? `[CDP Firewall] ${p.name} blocked \u2014 bypasses framework reactivity` : `[CDP Firewall] ${p.name} detected \u2014 use proper interaction API`
|
|
3451
|
+
};
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
return null;
|
|
3248
3455
|
}
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3456
|
+
};
|
|
3457
|
+
|
|
3458
|
+
// src/cdp-interceptor/rules/mouse-trajectory.ts
|
|
3459
|
+
var TRACKER_KEY = "mouse-trajectory-tracker";
|
|
3460
|
+
var MAX_SAMPLES = 200;
|
|
3461
|
+
var MIN_SAMPLES_FOR_ANALYSIS = 5;
|
|
3462
|
+
var mouseTrajectoryRule = {
|
|
3463
|
+
id: "mouse-trajectory",
|
|
3464
|
+
name: "Mouse Trajectory Analysis",
|
|
3465
|
+
priority: 20,
|
|
3466
|
+
canHandle(ctx) {
|
|
3467
|
+
return ctx.method === "Input.dispatchMouseEvent";
|
|
3468
|
+
},
|
|
3469
|
+
evaluate(ctx) {
|
|
3470
|
+
const type = ctx.params.type;
|
|
3471
|
+
const x = ctx.params.x;
|
|
3472
|
+
const y = ctx.params.y;
|
|
3473
|
+
if (typeof x !== "number" || typeof y !== "number") return null;
|
|
3474
|
+
let tracker = ctx.sessionState.get(TRACKER_KEY);
|
|
3475
|
+
if (!tracker) {
|
|
3476
|
+
tracker = { samples: [], lastMouseDownAt: 0, isDragging: false };
|
|
3477
|
+
ctx.sessionState.set(TRACKER_KEY, tracker);
|
|
3261
3478
|
}
|
|
3262
|
-
if (
|
|
3263
|
-
|
|
3479
|
+
if (type === "mousePressed") {
|
|
3480
|
+
const priorResult = analyzeTrajectory(tracker.samples);
|
|
3481
|
+
tracker.lastMouseDownAt = Date.now();
|
|
3482
|
+
tracker.isDragging = true;
|
|
3483
|
+
tracker.samples = [];
|
|
3484
|
+
return priorResult;
|
|
3264
3485
|
}
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
return new Promise((resolve, reject) => {
|
|
3271
|
-
const timeout = setTimeout(() => {
|
|
3272
|
-
this.pending.delete(id);
|
|
3273
|
-
reject(new Error(`CDP timeout: ${method} (${timeoutMs}ms)`));
|
|
3274
|
-
}, timeoutMs);
|
|
3275
|
-
this.pending.set(id, {
|
|
3276
|
-
resolve: (v) => {
|
|
3277
|
-
clearTimeout(timeout);
|
|
3278
|
-
this.pending.delete(id);
|
|
3279
|
-
resolve(v);
|
|
3280
|
-
},
|
|
3281
|
-
reject: (err) => {
|
|
3282
|
-
clearTimeout(timeout);
|
|
3283
|
-
this.pending.delete(id);
|
|
3284
|
-
reject(err);
|
|
3285
|
-
},
|
|
3286
|
-
method,
|
|
3287
|
-
timeout
|
|
3288
|
-
});
|
|
3289
|
-
const data = JSON.stringify(message);
|
|
3290
|
-
try {
|
|
3291
|
-
this.ws.send(data);
|
|
3292
|
-
} catch (err) {
|
|
3293
|
-
clearTimeout(timeout);
|
|
3294
|
-
this.pending.delete(id);
|
|
3295
|
-
reject(new Error(`CDP send failed: ${method} \u2014 ${err instanceof Error ? err.message : String(err)}`));
|
|
3296
|
-
}
|
|
3297
|
-
});
|
|
3298
|
-
}
|
|
3299
|
-
/**
|
|
3300
|
-
* Subscribe to a CDP event.
|
|
3301
|
-
*
|
|
3302
|
-
* @param event — full event name (e.g. "Page.frameNavigated")
|
|
3303
|
-
* @param handler — called with the event params
|
|
3304
|
-
* @param sessionId — optional session filter
|
|
3305
|
-
*/
|
|
3306
|
-
on(event, handler) {
|
|
3307
|
-
return super.on(event, handler);
|
|
3308
|
-
}
|
|
3309
|
-
once(event, handler) {
|
|
3310
|
-
return super.once(event, handler);
|
|
3311
|
-
}
|
|
3312
|
-
/** Remove an event listener */
|
|
3313
|
-
off(event, handler) {
|
|
3314
|
-
super.off(event, handler);
|
|
3315
|
-
return this;
|
|
3316
|
-
}
|
|
3317
|
-
/**
|
|
3318
|
-
* Subscribe to a CDP event for a specific session.
|
|
3319
|
-
* Returns an unsubscribe function.
|
|
3320
|
-
*/
|
|
3321
|
-
subscribe(event, sessionId, handler) {
|
|
3322
|
-
const wrapper = (params, sid) => {
|
|
3323
|
-
if (sid === sessionId || !sessionId && !sid) handler(params);
|
|
3324
|
-
};
|
|
3325
|
-
this.on(event, wrapper);
|
|
3326
|
-
return () => this.off(event, wrapper);
|
|
3327
|
-
}
|
|
3328
|
-
/** Close the WebSocket */
|
|
3329
|
-
async close() {
|
|
3330
|
-
if (this.closed) return;
|
|
3331
|
-
this.closed = true;
|
|
3332
|
-
this.closeReason = "closed by caller";
|
|
3333
|
-
if (this.keepaliveTimer) {
|
|
3334
|
-
clearInterval(this.keepaliveTimer);
|
|
3335
|
-
this.keepaliveTimer = null;
|
|
3336
|
-
}
|
|
3337
|
-
if (this.pongTimer) {
|
|
3338
|
-
clearTimeout(this.pongTimer);
|
|
3339
|
-
this.pongTimer = null;
|
|
3486
|
+
if (type === "mouseReleased") {
|
|
3487
|
+
tracker.isDragging = false;
|
|
3488
|
+
const result = analyzeTrajectory(tracker.samples);
|
|
3489
|
+
tracker.samples = [];
|
|
3490
|
+
return result;
|
|
3340
3491
|
}
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3492
|
+
if (type === "mouseMoved") {
|
|
3493
|
+
const now = Date.now();
|
|
3494
|
+
const prevSample = tracker.samples[tracker.samples.length - 1];
|
|
3495
|
+
const distanceTraveled = prevSample ? prevSample.distanceTraveled + distance(prevSample.x, prevSample.y, x, y) : 0;
|
|
3496
|
+
const sample = { x, y, timestamp: now, distanceTraveled };
|
|
3497
|
+
tracker.samples.push(sample);
|
|
3498
|
+
if (tracker.samples.length > MAX_SAMPLES) {
|
|
3499
|
+
tracker.samples.shift();
|
|
3500
|
+
}
|
|
3501
|
+
return null;
|
|
3345
3502
|
}
|
|
3346
|
-
if (
|
|
3347
|
-
|
|
3503
|
+
if (type === "mouseReleased" && tracker.samples.length < MIN_SAMPLES_FOR_ANALYSIS) {
|
|
3504
|
+
tracker.samples = [];
|
|
3505
|
+
return null;
|
|
3348
3506
|
}
|
|
3507
|
+
return null;
|
|
3349
3508
|
}
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3509
|
+
};
|
|
3510
|
+
function analyzeTrajectory(samples) {
|
|
3511
|
+
if (samples.length < MIN_SAMPLES_FOR_ANALYSIS) return null;
|
|
3512
|
+
const issues = [];
|
|
3513
|
+
const startX = samples[0].x;
|
|
3514
|
+
const startY = samples[0].y;
|
|
3515
|
+
const endX = samples[samples.length - 1].x;
|
|
3516
|
+
const endY = samples[samples.length - 1].y;
|
|
3517
|
+
const lineLength = distance(startX, startY, endX, endY);
|
|
3518
|
+
const collinearityResult = checkCollinearity(samples, startX, startY, endX, endY, lineLength);
|
|
3519
|
+
if (collinearityResult) {
|
|
3520
|
+
issues.push(collinearityResult);
|
|
3353
3521
|
}
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
let msg;
|
|
3358
|
-
try {
|
|
3359
|
-
msg = JSON.parse(raw.toString());
|
|
3360
|
-
} catch {
|
|
3361
|
-
return;
|
|
3362
|
-
}
|
|
3363
|
-
if (msg.id !== void 0) {
|
|
3364
|
-
const pending = this.pending.get(msg.id);
|
|
3365
|
-
if (!pending) return;
|
|
3366
|
-
if (msg.error) {
|
|
3367
|
-
pending.reject(new CDPProtocolError(msg.error.code, msg.error.message, pending.method));
|
|
3368
|
-
} else {
|
|
3369
|
-
pending.resolve(msg.result ?? {});
|
|
3370
|
-
}
|
|
3371
|
-
return;
|
|
3372
|
-
}
|
|
3373
|
-
if (msg.method) {
|
|
3374
|
-
this.emit(msg.method, msg.params ?? {}, msg.sessionId);
|
|
3375
|
-
this.emit("*", msg.method, msg.params ?? {}, msg.sessionId);
|
|
3376
|
-
}
|
|
3377
|
-
});
|
|
3378
|
-
this.ws.on("close", (code, reason) => {
|
|
3379
|
-
if (this.closed) return;
|
|
3380
|
-
this.closed = true;
|
|
3381
|
-
this.closeReason = `WebSocket closed: ${code} ${reason?.toString() ?? ""}`.trim();
|
|
3382
|
-
if (this.keepaliveTimer) {
|
|
3383
|
-
clearInterval(this.keepaliveTimer);
|
|
3384
|
-
this.keepaliveTimer = null;
|
|
3385
|
-
}
|
|
3386
|
-
for (const [id, pending] of this.pending) {
|
|
3387
|
-
clearTimeout(pending.timeout);
|
|
3388
|
-
pending.reject(new Error(`Connection closed: ${pending.method}`));
|
|
3389
|
-
this.pending.delete(id);
|
|
3390
|
-
}
|
|
3391
|
-
this.emit("disconnect");
|
|
3392
|
-
});
|
|
3393
|
-
this.ws.on("error", (err) => {
|
|
3394
|
-
if (this.closed) return;
|
|
3395
|
-
this.emit("ws-error", err);
|
|
3396
|
-
});
|
|
3522
|
+
const velocityResult = checkConstantVelocity(samples);
|
|
3523
|
+
if (velocityResult) {
|
|
3524
|
+
issues.push(velocityResult);
|
|
3397
3525
|
}
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
method;
|
|
3402
|
-
data;
|
|
3403
|
-
constructor(code, message, method, data) {
|
|
3404
|
-
super(`CDP error [${code}] in ${method}: ${message}`);
|
|
3405
|
-
this.name = "CDPProtocolError";
|
|
3406
|
-
this.code = code;
|
|
3407
|
-
this.method = method;
|
|
3408
|
-
this.data = data;
|
|
3526
|
+
const jitterResult = checkJitter(samples);
|
|
3527
|
+
if (jitterResult) {
|
|
3528
|
+
issues.push(jitterResult);
|
|
3409
3529
|
}
|
|
3410
|
-
|
|
3530
|
+
if (issues.length === 0) return null;
|
|
3531
|
+
const stopX = samples[samples.length - 1].x;
|
|
3532
|
+
const stopY = samples[samples.length - 1].y;
|
|
3533
|
+
return {
|
|
3534
|
+
ruleId: "mouse-trajectory",
|
|
3535
|
+
action: "pass",
|
|
3536
|
+
severity: "danger",
|
|
3537
|
+
reason: `Suspicious mouse trajectory: ${issues.join("; ")}`,
|
|
3538
|
+
suggestion: `This mouse movement appears automated (straight line A\u2192B, no natural variation).
|
|
3539
|
+
Use a humanized mouse API that generates:
|
|
3540
|
+
- Bezier curves instead of straight lines
|
|
3541
|
+
- Random acceleration/deceleration
|
|
3542
|
+
- 1-3px micro-jitter per sample
|
|
3411
3543
|
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
if (options.cdpEndpoint) {
|
|
3418
|
-
wsEndpoint = await connectToCDP(options.cdpEndpoint);
|
|
3419
|
-
} else {
|
|
3420
|
-
const result = await launchChrome({
|
|
3421
|
-
executablePath: options.executablePath,
|
|
3422
|
-
headless: options.headless,
|
|
3423
|
-
args: options.args,
|
|
3424
|
-
userDataDir: options.userDataDir,
|
|
3425
|
-
timeout: options.timeout,
|
|
3426
|
-
env: options.env
|
|
3427
|
-
});
|
|
3428
|
-
wsEndpoint = result.wsEndpoint;
|
|
3429
|
-
childProcess = result.process;
|
|
3430
|
-
tmpDir = result.tmpDir;
|
|
3431
|
-
}
|
|
3432
|
-
const conn = new CDPConnection(wsEndpoint);
|
|
3433
|
-
await conn.ready();
|
|
3434
|
-
const httpEndpoint = options.cdpEndpoint && !options.cdpEndpoint.startsWith("ws") ? options.cdpEndpoint : void 0;
|
|
3435
|
-
const browser = new XBBrowserImpl(conn, childProcess, tmpDir, httpEndpoint);
|
|
3436
|
-
return { browser, wsEndpoint };
|
|
3544
|
+
Example: The 'faker' or 'ghost-cursor' libraries generate realistic mouse paths.
|
|
3545
|
+
Target was: (${Math.round(startX)}, ${Math.round(startY)}) \u2192 (${Math.round(stopX)}, ${Math.round(stopY)})`,
|
|
3546
|
+
errorCode: -32002,
|
|
3547
|
+
errorMessage: "[CDP Firewall] Automated mouse trajectory blocked \u2014 appears non-human"
|
|
3548
|
+
};
|
|
3437
3549
|
}
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
|
|
3441
|
-
|
|
3442
|
-
// src/cdp-interceptor/rules/shared.ts
|
|
3443
|
-
var PLAYWRIGHT_INTERNAL_MARKERS = [
|
|
3444
|
-
"__commonJS",
|
|
3445
|
-
"module.exports",
|
|
3446
|
-
"__require",
|
|
3447
|
-
"__toESM",
|
|
3448
|
-
"inject_utils"
|
|
3449
|
-
];
|
|
3450
|
-
function isPlaywrightInternal(code) {
|
|
3451
|
-
return PLAYWRIGHT_INTERNAL_MARKERS.some((marker) => code.includes(marker));
|
|
3550
|
+
function distance(x1, y1, x2, y2) {
|
|
3551
|
+
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
|
3452
3552
|
}
|
|
3453
|
-
function
|
|
3454
|
-
if (
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
}
|
|
3553
|
+
function checkCollinearity(samples, x1, y1, x2, y2, lineLength) {
|
|
3554
|
+
if (lineLength < 5) return null;
|
|
3555
|
+
let maxDeviation = 0;
|
|
3556
|
+
const dx = x2 - x1;
|
|
3557
|
+
const dy = y2 - y1;
|
|
3558
|
+
for (let i = 1; i < samples.length - 1; i++) {
|
|
3559
|
+
const { x, y } = samples[i];
|
|
3560
|
+
const cross = Math.abs(dy * x - dx * y + x2 * y1 - y2 * x1);
|
|
3561
|
+
const dev = cross / lineLength;
|
|
3562
|
+
if (dev > maxDeviation) maxDeviation = dev;
|
|
3460
3563
|
}
|
|
3461
|
-
if (
|
|
3462
|
-
|
|
3463
|
-
if (typeof decl === "string" && decl.includes("utilityScript.evaluate")) {
|
|
3464
|
-
return extractAllStrings(ctx.params.arguments);
|
|
3465
|
-
}
|
|
3466
|
-
if (typeof decl === "string") return decl;
|
|
3467
|
-
}
|
|
3468
|
-
return null;
|
|
3469
|
-
}
|
|
3470
|
-
function extractAllStrings(rawArgs) {
|
|
3471
|
-
if (!Array.isArray(rawArgs)) return null;
|
|
3472
|
-
const strings = [];
|
|
3473
|
-
for (const arg of rawArgs) {
|
|
3474
|
-
if (arg && typeof arg === "object" && "value" in arg) {
|
|
3475
|
-
const val = arg.value;
|
|
3476
|
-
if (typeof val === "string" && val.length > 5 && !["true", "false"].includes(val)) {
|
|
3477
|
-
strings.push(val);
|
|
3478
|
-
}
|
|
3479
|
-
}
|
|
3480
|
-
}
|
|
3481
|
-
return strings.length > 0 ? strings.join("\n") : null;
|
|
3482
|
-
}
|
|
3483
|
-
|
|
3484
|
-
// src/cdp-interceptor/rules/dom-mutation.ts
|
|
3485
|
-
var DOM_PATTERNS = [
|
|
3486
|
-
// ── P0: Value/Checked (bypasses React onChange) ────────────
|
|
3487
|
-
{ pattern: /\.value\s*=\s*(?!["'\s]*$)/, name: ".value =", severity: "danger", action: "block", errorCode: -32001, suggestion: "Use page.fill(selector, value) which dispatches proper input/change events." },
|
|
3488
|
-
{ pattern: /\.checked\s*=\s*(?:true|false)/, name: ".checked =", severity: "danger", action: "block", errorCode: -32001, suggestion: "Use page.check(selector) or page.uncheck(selector)." },
|
|
3489
|
-
{ pattern: /\.indeterminate\s*=\s*true/, name: ".indeterminate =", severity: "warn", action: "block", errorCode: -32021, suggestion: "No human can set indeterminate state \u2014 remove this call." },
|
|
3490
|
-
{ pattern: /\.valueAsDate\s*=/, name: ".valueAsDate =", severity: "warn", action: "block", errorCode: -32022, suggestion: "Use page.fill() with formatted date string instead." },
|
|
3491
|
-
{ pattern: /\.valueAsNumber\s*=/, name: ".valueAsNumber =", severity: "warn", action: "block", errorCode: -32022, suggestion: "Use page.fill() with numeric string instead." },
|
|
3492
|
-
// ── P1: Select/Option (bypasses React onChange on select) ─
|
|
3493
|
-
{ pattern: /\.selectedIndex\s*=\s*\d+/, name: ".selectedIndex =", severity: "danger", action: "block", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
|
|
3494
|
-
{ pattern: /(?:options|children)\s*\[[^\]]*\]\s*\.\s*selected\s*=\s*(?:true|false)/, name: "options[N].selected =", severity: "danger", action: "block", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
|
|
3495
|
-
{ pattern: /\.value\s*=\s*["'][^"']*["']\s*[;)]?\s*$/, name: "selectElement.value =", severity: "warn", action: "block", errorCode: -32023, suggestion: "For select elements, use page.selectOption(selector, value)." },
|
|
3496
|
-
// ── P2: Content properties (bypasses virtual DOM diffing) ──
|
|
3497
|
-
{ pattern: /\.innerHTML\s*=/, name: ".innerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: ".innerHTML bypasses React/Vue diffing. Use component state or page.setContent()." },
|
|
3498
|
-
{ pattern: /\.outerHTML\s*=/, name: ".outerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: "outerHTML replacement destroys React fiber tree. Use page.setContent()." },
|
|
3499
|
-
{ pattern: /\.innerText\s*=/, name: ".innerText =", severity: "warn", action: "block", errorCode: -32024, suggestion: "Framework components should be updated via state, not innerText." },
|
|
3500
|
-
{ pattern: /\.textContent\s*=/, name: ".textContent =", severity: "warn", action: "block", errorCode: -32024, suggestion: "textContent bypasses React/Vue diffing. Use component state instead." },
|
|
3501
|
-
{ pattern: /\.outerText\s*=/, name: ".outerText =", severity: "warn", action: "block", errorCode: -32024, suggestion: "Non-standard property \u2014 use proper framework update methods." },
|
|
3502
|
-
{ pattern: /nodeValue\s*=/, name: "node.nodeValue =", severity: "info", action: "block", errorCode: -32025, suggestion: "Direct text node mutation. Use textContent instead if needed." },
|
|
3503
|
-
// ── P3: Style properties ──────────────────────────────────
|
|
3504
|
-
{ pattern: /\.style\s*=\s*["']/, name: ".style = (string override)", severity: "info", action: "pass", errorCode: -32008, suggestion: 'Setting style as a string overwrites CSSStyleDeclaration. Use element.style.prop = "value".' },
|
|
3505
|
-
{ pattern: /\.style\.cssText\s*=/, name: ".style.cssText =", severity: "info", action: "pass", errorCode: -32008, suggestion: "style.cssText replacement destroys inline styles \u2014 use individual property set." },
|
|
3506
|
-
{ pattern: /\.style\.setProperty\s*\(/, name: ".style.setProperty()", severity: "info", action: "pass", errorCode: -32008, suggestion: "Direct style property set bypasses CSS transitions." },
|
|
3507
|
-
{ pattern: /\.style\.removeProperty\s*\(/, name: ".style.removeProperty()", severity: "info", action: "pass", errorCode: -32026, suggestion: "Style removal without user interaction is suspicious." },
|
|
3508
|
-
{ pattern: /\.style\.animation\s*=/, name: ".style.animation =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Forcing animation state via CDP is detectable." },
|
|
3509
|
-
{ pattern: /\.style\.transition\s*=/, name: ".style.transition =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Manipulating CSS transitions is rare in normal browsing." },
|
|
3510
|
-
// ── P4: Class/Attribute properties ────────────────────────
|
|
3511
|
-
{ pattern: /\.className\s*=/, name: ".className =", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or a Playwright locator instead." },
|
|
3512
|
-
{ pattern: /\.classList\.add\s*\(/, name: ".classList.add()", severity: "info", action: "pass", errorCode: -32009, suggestion: "classList manipulation via CDP bypasses framework state tracking." },
|
|
3513
|
-
{ pattern: /\.classList\.remove\s*\(/, name: ".classList.remove()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or attribute selectors instead." },
|
|
3514
|
-
{ pattern: /\.classList\.toggle\s*\(/, name: ".classList.toggle()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Toggle without user interaction is detectable." },
|
|
3515
|
-
{ pattern: /\.classList\.replace\s*\(/, name: ".classList.replace()", severity: "info", action: "pass", errorCode: -32028, suggestion: "Rare operation \u2014 likely automated." },
|
|
3516
|
-
// ── P5: Attribute manipulation ────────────────────────────
|
|
3517
|
-
{ pattern: /\.setAttribute\s*\(/, name: ".setAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "setAttribute bypasses framework attribute tracking. Use component state." },
|
|
3518
|
-
{ pattern: /\.removeAttribute\s*\(/, name: ".removeAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "Attribute removal without user interaction is suspicious." },
|
|
3519
|
-
{ pattern: /\.toggleAttribute\s*\(/, name: ".toggleAttribute()", severity: "info", action: "pass", errorCode: -32029, suggestion: "Attribute toggling via CDP is detectable." },
|
|
3520
|
-
{ pattern: /\.dataset\.\w+\s*=/, name: ".dataset.* =", severity: "info", action: "pass", errorCode: -32030, suggestion: "dataset mutations via evaluate bypass native mutation observers." },
|
|
3521
|
-
// ── P6: Focus/Selection properties ────────────────────────
|
|
3522
|
-
{ pattern: /\.selectionStart\s*=/, name: ".selectionStart =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Setting cursor position without focus is detectable." },
|
|
3523
|
-
{ pattern: /\.selectionEnd\s*=/, name: ".selectionEnd =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Selection range manipulation without user input is suspicious." },
|
|
3524
|
-
{ pattern: /\.selectionDirection\s*=/, name: ".selectionDirection =", severity: "info", action: "pass", errorCode: -32031, suggestion: "Selection direction changes normally via mouse drag." },
|
|
3525
|
-
// ── P7: Boolean properties ────────────────────────────────
|
|
3526
|
-
{ pattern: /\.disabled\s*=/, name: ".disabled =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Disabling elements via CDP mid-interaction is detectable." },
|
|
3527
|
-
{ pattern: /\.readOnly\s*=/, name: ".readOnly =", severity: "info", action: "pass", errorCode: -32032, suggestion: "readOnly changes without user action." },
|
|
3528
|
-
{ pattern: /\.hidden\s*=/, name: ".hidden =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Hiding elements is a common scraper tactic \u2014 detectable." },
|
|
3529
|
-
{ pattern: /\.required\s*=/, name: ".required =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Validation constraint changes mid-session." },
|
|
3530
|
-
{ pattern: /\.multiple\s*=/, name: ".multiple =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Multiple attribute toggle is rare in normal browsing." },
|
|
3531
|
-
{ pattern: /\.autofocus\s*=/, name: ".autofocus =", severity: "info", action: "pass", errorCode: -32032, suggestion: "autofocus changes mid-session are suspicious." },
|
|
3532
|
-
// ── P8: Frame/Navigation properties ───────────────────────
|
|
3533
|
-
{ pattern: /\.src\s*=/, name: ".src = (on img/iframe/script)", severity: "info", action: "pass", errorCode: -32013, suggestion: "Changing src via CDP bypasses user interaction. Use page.click() on the element." },
|
|
3534
|
-
{ pattern: /\.href\s*=/, name: ".href =", severity: "info", action: "pass", errorCode: -32033, suggestion: "Changing anchor href via evaluate \u2014 use page.click() instead." },
|
|
3535
|
-
{ pattern: /\.action\s*=/, name: "form.action =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Changing form action URL is highly suspicious." },
|
|
3536
|
-
{ pattern: /\.method\s*=/, name: "form.method =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Form method changes without user interaction." },
|
|
3537
|
-
{ pattern: /\.target\s*=/, name: "link/area.target =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Link target manipulation via CDP." },
|
|
3538
|
-
// ── P9: Media properties ──────────────────────────────────
|
|
3539
|
-
{ pattern: /\.currentTime\s*=/, name: "media.currentTime = (seeking)", severity: "info", action: "pass", errorCode: -32014, suggestion: "Video seeking without user interaction \u2014 common scraper pattern." },
|
|
3540
|
-
{ pattern: /\.playbackRate\s*=/, name: "media.playbackRate =", severity: "info", action: "pass", errorCode: -32014, suggestion: "Changing playback speed is detectable automation signal." },
|
|
3541
|
-
{ pattern: /\.volume\s*=/, name: "media.volume =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Volume setting via evaluate is suspicious." },
|
|
3542
|
-
{ pattern: /\.muted\s*=/, name: "media.muted =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Muting media without user click is suspicious." },
|
|
3543
|
-
// ── P10: Element geometry ─────────────────────────────────
|
|
3544
|
-
{ pattern: /\.scrollTop\s*=/, name: ".scrollTop =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Programmatic scroll without user gesture. Use page.mouse.wheel()." },
|
|
3545
|
-
{ pattern: /\.scrollLeft\s*=/, name: ".scrollLeft =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Horizontal scroll without user gesture." },
|
|
3546
|
-
{ pattern: /\.scrollTo\s*\(/, name: ".scrollTo()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollTo bypasses user scroll detection." },
|
|
3547
|
-
{ pattern: /\.scrollBy\s*\(/, name: ".scrollBy()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollBy without user gesture." },
|
|
3548
|
-
{ pattern: /\.scrollIntoView\s*\(/, name: ".scrollIntoView()", severity: "info", action: "pass", errorCode: -32015, suggestion: "scrollIntoView is a common bot pattern. Let Playwright handle scrolling." },
|
|
3549
|
-
// ── P11: Shadow DOM ───────────────────────────────────────
|
|
3550
|
-
{ pattern: /\.shadowRoot\s*=/, name: ".shadowRoot = (override)", severity: "info", action: "block", errorCode: -32036, suggestion: "ShadowRoot is read-only \u2014 this set attempt is detectable." },
|
|
3551
|
-
// ── P12: Force reflow / layout thrashing ──────────────────
|
|
3552
|
-
{ pattern: /\.offsetHeight\b(?!\s*===?\s*)/, name: ".offsetHeight read (forced reflow)", severity: "warn", action: "pass", errorCode: -32016, suggestion: "Reading offsetHeight triggers forced reflow \u2014 anti-crawlers detect this as layout probing." },
|
|
3553
|
-
{ pattern: /\.offsetWidth\b(?!\s*===?\s*)/, name: ".offsetWidth read (forced reflow)", severity: "warn", action: "pass", errorCode: -32016, suggestion: "Reading offsetWidth triggers forced reflow \u2014 detectable." },
|
|
3554
|
-
{ pattern: /getBoundingClientRect\s*\(/, name: "getBoundingClientRect()", severity: "warn", action: "pass", errorCode: -32037, suggestion: "getBoundingClientRect triggers reflow. Minimize calls." },
|
|
3555
|
-
{ pattern: /getComputedStyle\s*\(/, name: "getComputedStyle()", severity: "info", action: "pass", errorCode: -32038, suggestion: "getComputedStyle can trigger style recalculation." },
|
|
3556
|
-
{ pattern: /\.clientHeight\b/, name: ".clientHeight read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientHeight read triggers layout." },
|
|
3557
|
-
{ pattern: /\.clientWidth\b/, name: ".clientWidth read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientWidth read triggers layout." }
|
|
3558
|
-
];
|
|
3559
|
-
var domMutationRule = {
|
|
3560
|
-
id: "dom-mutation",
|
|
3561
|
-
name: "DOM Property Mutation Detection (50+ setters)",
|
|
3562
|
-
priority: 10,
|
|
3563
|
-
canHandle(ctx) {
|
|
3564
|
-
return ctx.method === "Runtime.evaluate" || ctx.method === "Runtime.callFunctionOn";
|
|
3565
|
-
},
|
|
3566
|
-
evaluate(ctx) {
|
|
3567
|
-
const userCode = extractUserCode(ctx);
|
|
3568
|
-
if (!userCode) return null;
|
|
3569
|
-
for (const p of DOM_PATTERNS) {
|
|
3570
|
-
if (p.pattern.test(userCode)) {
|
|
3571
|
-
return {
|
|
3572
|
-
ruleId: "dom-mutation",
|
|
3573
|
-
action: p.action,
|
|
3574
|
-
severity: p.severity,
|
|
3575
|
-
reason: `Direct DOM property setter: "${p.name}". This bypasses framework event systems and is detectable as automation.`,
|
|
3576
|
-
suggestion: p.suggestion,
|
|
3577
|
-
errorCode: p.errorCode,
|
|
3578
|
-
errorMessage: p.severity === "danger" ? `[CDP Firewall] ${p.name} blocked \u2014 bypasses framework reactivity` : `[CDP Firewall] ${p.name} detected \u2014 use proper interaction API`
|
|
3579
|
-
};
|
|
3580
|
-
}
|
|
3581
|
-
}
|
|
3582
|
-
return null;
|
|
3583
|
-
}
|
|
3584
|
-
};
|
|
3585
|
-
|
|
3586
|
-
// src/cdp-interceptor/rules/mouse-trajectory.ts
|
|
3587
|
-
var TRACKER_KEY = "mouse-trajectory-tracker";
|
|
3588
|
-
var MAX_SAMPLES = 200;
|
|
3589
|
-
var MIN_SAMPLES_FOR_ANALYSIS = 5;
|
|
3590
|
-
var mouseTrajectoryRule = {
|
|
3591
|
-
id: "mouse-trajectory",
|
|
3592
|
-
name: "Mouse Trajectory Analysis",
|
|
3593
|
-
priority: 20,
|
|
3594
|
-
canHandle(ctx) {
|
|
3595
|
-
return ctx.method === "Input.dispatchMouseEvent";
|
|
3596
|
-
},
|
|
3597
|
-
evaluate(ctx) {
|
|
3598
|
-
const type = ctx.params.type;
|
|
3599
|
-
const x = ctx.params.x;
|
|
3600
|
-
const y = ctx.params.y;
|
|
3601
|
-
if (typeof x !== "number" || typeof y !== "number") return null;
|
|
3602
|
-
let tracker = ctx.sessionState.get(TRACKER_KEY);
|
|
3603
|
-
if (!tracker) {
|
|
3604
|
-
tracker = { samples: [], lastMouseDownAt: 0, isDragging: false };
|
|
3605
|
-
ctx.sessionState.set(TRACKER_KEY, tracker);
|
|
3606
|
-
}
|
|
3607
|
-
if (type === "mousePressed") {
|
|
3608
|
-
const priorResult = analyzeTrajectory(tracker.samples);
|
|
3609
|
-
tracker.lastMouseDownAt = Date.now();
|
|
3610
|
-
tracker.isDragging = true;
|
|
3611
|
-
tracker.samples = [];
|
|
3612
|
-
return priorResult;
|
|
3613
|
-
}
|
|
3614
|
-
if (type === "mouseReleased") {
|
|
3615
|
-
tracker.isDragging = false;
|
|
3616
|
-
const result = analyzeTrajectory(tracker.samples);
|
|
3617
|
-
tracker.samples = [];
|
|
3618
|
-
return result;
|
|
3619
|
-
}
|
|
3620
|
-
if (type === "mouseMoved") {
|
|
3621
|
-
const now = Date.now();
|
|
3622
|
-
const prevSample = tracker.samples[tracker.samples.length - 1];
|
|
3623
|
-
const distanceTraveled = prevSample ? prevSample.distanceTraveled + distance(prevSample.x, prevSample.y, x, y) : 0;
|
|
3624
|
-
const sample = { x, y, timestamp: now, distanceTraveled };
|
|
3625
|
-
tracker.samples.push(sample);
|
|
3626
|
-
if (tracker.samples.length > MAX_SAMPLES) {
|
|
3627
|
-
tracker.samples.shift();
|
|
3628
|
-
}
|
|
3629
|
-
return null;
|
|
3630
|
-
}
|
|
3631
|
-
if (type === "mouseReleased" && tracker.samples.length < MIN_SAMPLES_FOR_ANALYSIS) {
|
|
3632
|
-
tracker.samples = [];
|
|
3633
|
-
return null;
|
|
3634
|
-
}
|
|
3635
|
-
return null;
|
|
3636
|
-
}
|
|
3637
|
-
};
|
|
3638
|
-
function analyzeTrajectory(samples) {
|
|
3639
|
-
if (samples.length < MIN_SAMPLES_FOR_ANALYSIS) return null;
|
|
3640
|
-
const issues = [];
|
|
3641
|
-
const startX = samples[0].x;
|
|
3642
|
-
const startY = samples[0].y;
|
|
3643
|
-
const endX = samples[samples.length - 1].x;
|
|
3644
|
-
const endY = samples[samples.length - 1].y;
|
|
3645
|
-
const lineLength = distance(startX, startY, endX, endY);
|
|
3646
|
-
const collinearityResult = checkCollinearity(samples, startX, startY, endX, endY, lineLength);
|
|
3647
|
-
if (collinearityResult) {
|
|
3648
|
-
issues.push(collinearityResult);
|
|
3649
|
-
}
|
|
3650
|
-
const velocityResult = checkConstantVelocity(samples);
|
|
3651
|
-
if (velocityResult) {
|
|
3652
|
-
issues.push(velocityResult);
|
|
3653
|
-
}
|
|
3654
|
-
const jitterResult = checkJitter(samples);
|
|
3655
|
-
if (jitterResult) {
|
|
3656
|
-
issues.push(jitterResult);
|
|
3657
|
-
}
|
|
3658
|
-
if (issues.length === 0) return null;
|
|
3659
|
-
const stopX = samples[samples.length - 1].x;
|
|
3660
|
-
const stopY = samples[samples.length - 1].y;
|
|
3661
|
-
return {
|
|
3662
|
-
ruleId: "mouse-trajectory",
|
|
3663
|
-
action: "block",
|
|
3664
|
-
severity: "danger",
|
|
3665
|
-
reason: `Suspicious mouse trajectory: ${issues.join("; ")}`,
|
|
3666
|
-
suggestion: `This mouse movement appears automated (straight line A\u2192B, no natural variation).
|
|
3667
|
-
Use a humanized mouse API that generates:
|
|
3668
|
-
- Bezier curves instead of straight lines
|
|
3669
|
-
- Random acceleration/deceleration
|
|
3670
|
-
- 1-3px micro-jitter per sample
|
|
3671
|
-
|
|
3672
|
-
Example: The 'faker' or 'ghost-cursor' libraries generate realistic mouse paths.
|
|
3673
|
-
Target was: (${Math.round(startX)}, ${Math.round(startY)}) \u2192 (${Math.round(stopX)}, ${Math.round(stopY)})`,
|
|
3674
|
-
errorCode: -32002,
|
|
3675
|
-
errorMessage: "[CDP Firewall] Automated mouse trajectory blocked \u2014 appears non-human"
|
|
3676
|
-
};
|
|
3677
|
-
}
|
|
3678
|
-
function distance(x1, y1, x2, y2) {
|
|
3679
|
-
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
|
3680
|
-
}
|
|
3681
|
-
function checkCollinearity(samples, x1, y1, x2, y2, lineLength) {
|
|
3682
|
-
if (lineLength < 5) return null;
|
|
3683
|
-
let maxDeviation = 0;
|
|
3684
|
-
const dx = x2 - x1;
|
|
3685
|
-
const dy = y2 - y1;
|
|
3686
|
-
for (let i = 1; i < samples.length - 1; i++) {
|
|
3687
|
-
const { x, y } = samples[i];
|
|
3688
|
-
const cross = Math.abs(dy * x - dx * y + x2 * y1 - y2 * x1);
|
|
3689
|
-
const dev = cross / lineLength;
|
|
3690
|
-
if (dev > maxDeviation) maxDeviation = dev;
|
|
3691
|
-
}
|
|
3692
|
-
if (maxDeviation < 1.5 && lineLength > 20) {
|
|
3693
|
-
return `Perfectly straight line (max deviation ${maxDeviation.toFixed(1)}px over ${lineLength.toFixed(0)}px)`;
|
|
3564
|
+
if (maxDeviation < 1.5 && lineLength > 20) {
|
|
3565
|
+
return `Perfectly straight line (max deviation ${maxDeviation.toFixed(1)}px over ${lineLength.toFixed(0)}px)`;
|
|
3694
3566
|
}
|
|
3695
3567
|
if (maxDeviation < 0.5 && lineLength > 10) {
|
|
3696
3568
|
return `Near-perfect straight line (max deviation ${maxDeviation.toFixed(1)}px)`;
|
|
@@ -3802,7 +3674,7 @@ function analyzeKeyTiming(samples) {
|
|
|
3802
3674
|
if (allIdentical) {
|
|
3803
3675
|
return {
|
|
3804
3676
|
ruleId: "input-keystroke",
|
|
3805
|
-
action: "
|
|
3677
|
+
action: "pass",
|
|
3806
3678
|
severity: "danger",
|
|
3807
3679
|
reason: `All ${intervals.length} keystroke intervals are exactly ${intervals[0]}ms \u2014 impossible for human typing.`,
|
|
3808
3680
|
suggestion: `Use page.fill(selector, text) instead of page.type() with delay.
|
|
@@ -3814,7 +3686,7 @@ Or add random variation: page.type(selector, text, {delay: 50 + Math.random() *
|
|
|
3814
3686
|
if (cv < 0.08) {
|
|
3815
3687
|
return {
|
|
3816
3688
|
ruleId: "input-keystroke",
|
|
3817
|
-
action: "
|
|
3689
|
+
action: "pass",
|
|
3818
3690
|
severity: "warn",
|
|
3819
3691
|
reason: `Unnatural keystroke timing (CV=${cv.toFixed(3)}). Human typing has CV > 0.2 on average.`,
|
|
3820
3692
|
suggestion: `Add random variation to your typing delay: page.type(selector, text, {delay: 50 + Math.random() * 80}).`,
|
|
@@ -3910,7 +3782,7 @@ function makeDecision(p, context) {
|
|
|
3910
3782
|
const suggestion = p.suggestionOverride ?? `Detected: "${p.name}" \u2014 an automation tool marker that anti-crawler systems immediately flag. Remove this pattern from your code.`;
|
|
3911
3783
|
return {
|
|
3912
3784
|
ruleId: "automation-signals",
|
|
3913
|
-
action: "
|
|
3785
|
+
action: "pass",
|
|
3914
3786
|
severity: p.severity,
|
|
3915
3787
|
reason: `Automation marker detected: "${p.name}". Context: "${context}..."`,
|
|
3916
3788
|
suggestion,
|
|
@@ -3985,7 +3857,7 @@ var fingerprintingRule = {
|
|
|
3985
3857
|
if (p.pattern.test(userCode)) {
|
|
3986
3858
|
return {
|
|
3987
3859
|
ruleId: "fingerprinting",
|
|
3988
|
-
action: "
|
|
3860
|
+
action: "pass",
|
|
3989
3861
|
severity: p.severity,
|
|
3990
3862
|
reason: `Browser fingerprinting API accessed: "${p.name}". Anti-crawler systems use this to identify your browser.`,
|
|
3991
3863
|
suggestion: p.suggestion,
|
|
@@ -4002,7 +3874,7 @@ var fingerprintingRule = {
|
|
|
4002
3874
|
var EVENT_PATTERNS = [
|
|
4003
3875
|
// ── Direct method calls (all isTrusted=false) ─────────
|
|
4004
3876
|
{ pattern: /\.click\s*\(\s*\)/, name: "el.click()", severity: "danger", errorCode: -32070, suggestion: "el.click() fires isTrusted=false events. Use page.click(selector) which uses Input.dispatchMouseEvent (isTrusted=true)." },
|
|
4005
|
-
{ pattern: /\.focus\s*\(\s*\)/, name: "el.focus()", severity: "
|
|
3877
|
+
{ pattern: /\.focus\s*\(\s*\)/, name: "el.focus()", severity: "warn", errorCode: -32071, suggestion: "el.focus() without user interaction is detectable. Use page.click(selector) which naturally focuses." },
|
|
4006
3878
|
{ pattern: /\.blur\s*\(\s*\)/, name: "el.blur()", severity: "danger", errorCode: -32071, suggestion: "el.blur() without user interaction. Avoid in automation scripts." },
|
|
4007
3879
|
{ pattern: /\.submit\s*\(\s*\)/, name: "el.submit()", severity: "danger", errorCode: -32072, suggestion: `form.submit() bypasses onSubmit handlers. Click the submit button: page.click('button[type="submit"]').` },
|
|
4008
3880
|
{ pattern: /\.reset\s*\(\s*\)/, name: "el.reset()", severity: "danger", errorCode: -32072, suggestion: "form.reset() without user action. Let the user clear fields manually." },
|
|
@@ -4017,7 +3889,7 @@ var EVENT_PATTERNS = [
|
|
|
4017
3889
|
{ pattern: /\.showPicker\s*\(\s*\)/, name: "HTMLInputElement.showPicker()", severity: "info", errorCode: -32076, suggestion: "Date/color picker shown without click \u2014 detectable." },
|
|
4018
3890
|
{ pattern: /\.reportValidity\s*\(\s*\)/, name: "el.reportValidity()", severity: "info", errorCode: -32076, suggestion: "Validity reporting without form submission attempt." },
|
|
4019
3891
|
// ── dispatchEvent with synthetic events (isTrusted=false) ──
|
|
4020
|
-
{ pattern: /dispatchEvent\s*\(\s*new\s+(?:Event|CustomEvent)\s*\(/, name: "dispatchEvent(new Event/CustomEvent)", severity: "
|
|
3892
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+(?:Event|CustomEvent)\s*\(/, name: "dispatchEvent(new Event/CustomEvent)", severity: "warn", errorCode: -32077, suggestion: "Synthetic events have isTrusted=false. Use Input.dispatch* CDP methods for trusted events." },
|
|
4021
3893
|
{ pattern: /dispatchEvent\s*\(\s*new\s+MouseEvent\s*\(/, name: "dispatchEvent(new MouseEvent)", severity: "danger", errorCode: -32077, suggestion: "Synthetic mouse events (isTrusted=false). Use Input.dispatchMouseEvent CDP method." },
|
|
4022
3894
|
{ pattern: /dispatchEvent\s*\(\s*new\s+KeyboardEvent\s*\(/, name: "dispatchEvent(new KeyboardEvent)", severity: "danger", errorCode: -32077, suggestion: "Synthetic keyboard events (isTrusted=false). Use Input.dispatchKeyEvent CDP method." },
|
|
4023
3895
|
{ pattern: /dispatchEvent\s*\(\s*new\s+FocusEvent\s*\(/, name: "dispatchEvent(new FocusEvent)", severity: "warn", errorCode: -32078, suggestion: "Synthetic focus events bypass user interaction." },
|
|
@@ -4046,7 +3918,9 @@ var eventSimulationRule = {
|
|
|
4046
3918
|
if (p.pattern.test(userCode)) {
|
|
4047
3919
|
return {
|
|
4048
3920
|
ruleId: "event-simulation",
|
|
4049
|
-
|
|
3921
|
+
// danger 级硬拦(el.click 等 100% 暴露的模式);
|
|
3922
|
+
// warning 级放行+提示(dispatchEvent 在部分合法场景如 DataTransfer paste 使用)
|
|
3923
|
+
action: p.severity === "danger" ? "block" : "pass",
|
|
4050
3924
|
severity: p.severity,
|
|
4051
3925
|
reason: `Event simulation detected: "${p.name}". Synthetic events have isTrusted=false and are 100% detectable.`,
|
|
4052
3926
|
suggestion: p.suggestion,
|
|
@@ -4105,7 +3979,7 @@ var emulationOverrideRule = {
|
|
|
4105
3979
|
if (ctx.method !== p.method) continue;
|
|
4106
3980
|
return {
|
|
4107
3981
|
ruleId: "emulation-override",
|
|
4108
|
-
action: "
|
|
3982
|
+
action: "pass",
|
|
4109
3983
|
severity: p.severity,
|
|
4110
3984
|
reason: `CDP emulation/override detected: "${p.name}". This creates detectable inconsistencies between the JS environment and real browser state.`,
|
|
4111
3985
|
suggestion: p.suggestion,
|
|
@@ -4135,7 +4009,7 @@ var networkAnomalyRule = {
|
|
|
4135
4009
|
if (!headerStr.includes("sec-ch-ua")) {
|
|
4136
4010
|
return {
|
|
4137
4011
|
ruleId: "network-anomaly",
|
|
4138
|
-
action: "
|
|
4012
|
+
action: "pass",
|
|
4139
4013
|
severity: "warn",
|
|
4140
4014
|
reason: "Network.setExtraHTTPHeaders called without Sec-CH-UA client hints \u2014 browser normally sends these.",
|
|
4141
4015
|
suggestion: "Modern browsers send Sec-CH-UA headers automatically. Adding custom headers without them creates detectable inconsistency.",
|
|
@@ -4149,7 +4023,7 @@ var networkAnomalyRule = {
|
|
|
4149
4023
|
case "Network.clearBrowserCache": {
|
|
4150
4024
|
return {
|
|
4151
4025
|
ruleId: "network-anomaly",
|
|
4152
|
-
action: "
|
|
4026
|
+
action: "pass",
|
|
4153
4027
|
severity: "warn",
|
|
4154
4028
|
reason: "Network.clearBrowserCache called \u2014 cache clearing mid-session is unnatural for real users.",
|
|
4155
4029
|
suggestion: "Avoid cache clearing during sessions. Start with a fresh profile if needed.",
|
|
@@ -4160,7 +4034,7 @@ var networkAnomalyRule = {
|
|
|
4160
4034
|
case "Network.clearBrowserCookies": {
|
|
4161
4035
|
return {
|
|
4162
4036
|
ruleId: "network-anomaly",
|
|
4163
|
-
action: "
|
|
4037
|
+
action: "pass",
|
|
4164
4038
|
severity: "warn",
|
|
4165
4039
|
reason: "Network.clearBrowserCookies called \u2014 wiping cookies mid-session is a scraper optimization.",
|
|
4166
4040
|
suggestion: "Cookies should only be cleared via normal browser flow (expiration, user action).",
|
|
@@ -4171,7 +4045,7 @@ var networkAnomalyRule = {
|
|
|
4171
4045
|
case "Network.setBlockedURLs": {
|
|
4172
4046
|
return {
|
|
4173
4047
|
ruleId: "network-anomaly",
|
|
4174
|
-
action: "
|
|
4048
|
+
action: "pass",
|
|
4175
4049
|
severity: "warn",
|
|
4176
4050
|
reason: "Network.setBlockedURLs blocks resource loading \u2014 this changes the page behavior and is detectable.",
|
|
4177
4051
|
suggestion: "Blocking images/fonts/etc creates measurable differences in performance and page rendering.",
|
|
@@ -4193,7 +4067,7 @@ var networkAnomalyRule = {
|
|
|
4193
4067
|
case "Fetch.enable": {
|
|
4194
4068
|
return {
|
|
4195
4069
|
ruleId: "network-anomaly",
|
|
4196
|
-
action: "
|
|
4070
|
+
action: "pass",
|
|
4197
4071
|
severity: "warn",
|
|
4198
4072
|
reason: "Fetch.enable intercepts all network requests \u2014 a man-in-the-middle approach used by scrapers.",
|
|
4199
4073
|
suggestion: "Do not use Fetch domain for network interception if avoiding detection.",
|
|
@@ -4205,179 +4079,496 @@ var networkAnomalyRule = {
|
|
|
4205
4079
|
return null;
|
|
4206
4080
|
}
|
|
4207
4081
|
}
|
|
4208
|
-
};
|
|
4209
|
-
|
|
4210
|
-
// src/cdp-interceptor/rules/page-lifecycle.ts
|
|
4211
|
-
var STAT_KEY = "lifecycle-tracker";
|
|
4212
|
-
var pageLifecycleRule = {
|
|
4213
|
-
id: "page-lifecycle",
|
|
4214
|
-
name: "Page Lifecycle Anomaly Detection (10+ patterns)",
|
|
4215
|
-
priority: 80,
|
|
4216
|
-
canHandle(ctx) {
|
|
4217
|
-
const m = ctx.method;
|
|
4218
|
-
return m === "Page.navigate" || m === "Page.captureScreenshot" || m === "Page.printToPDF" || m === "Page.reload" || m === "Page.close" || m === "Runtime.evaluate" || m === "Runtime.callFunctionOn";
|
|
4219
|
-
},
|
|
4220
|
-
evaluate(ctx) {
|
|
4221
|
-
let state = ctx.sessionState.get(STAT_KEY);
|
|
4222
|
-
if (!state) {
|
|
4223
|
-
state = { navigations: [], screenshots: 0, printToPDF: false, evaluateCount: 0, lastNavTime: 0, lastEvalTime: 0 };
|
|
4224
|
-
ctx.sessionState.set(STAT_KEY, state);
|
|
4082
|
+
};
|
|
4083
|
+
|
|
4084
|
+
// src/cdp-interceptor/rules/page-lifecycle.ts
|
|
4085
|
+
var STAT_KEY = "lifecycle-tracker";
|
|
4086
|
+
var pageLifecycleRule = {
|
|
4087
|
+
id: "page-lifecycle",
|
|
4088
|
+
name: "Page Lifecycle Anomaly Detection (10+ patterns)",
|
|
4089
|
+
priority: 80,
|
|
4090
|
+
canHandle(ctx) {
|
|
4091
|
+
const m = ctx.method;
|
|
4092
|
+
return m === "Page.navigate" || m === "Page.captureScreenshot" || m === "Page.printToPDF" || m === "Page.reload" || m === "Page.close" || m === "Runtime.evaluate" || m === "Runtime.callFunctionOn";
|
|
4093
|
+
},
|
|
4094
|
+
evaluate(ctx) {
|
|
4095
|
+
let state = ctx.sessionState.get(STAT_KEY);
|
|
4096
|
+
if (!state) {
|
|
4097
|
+
state = { navigations: [], screenshots: 0, printToPDF: false, evaluateCount: 0, lastNavTime: 0, lastEvalTime: 0 };
|
|
4098
|
+
ctx.sessionState.set(STAT_KEY, state);
|
|
4099
|
+
}
|
|
4100
|
+
const now = Date.now();
|
|
4101
|
+
switch (ctx.method) {
|
|
4102
|
+
case "Page.navigate": {
|
|
4103
|
+
state.navigations.push(now);
|
|
4104
|
+
if (state.navigations.length >= 2) {
|
|
4105
|
+
const prev = state.navigations[state.navigations.length - 2];
|
|
4106
|
+
const interval = now - prev;
|
|
4107
|
+
if (interval < 100) {
|
|
4108
|
+
return {
|
|
4109
|
+
ruleId: "page-lifecycle",
|
|
4110
|
+
action: "pass",
|
|
4111
|
+
severity: "warn",
|
|
4112
|
+
reason: `Multiple Page.navigate calls within ${interval}ms of each other \u2014 unnatural rapid navigation.`,
|
|
4113
|
+
suggestion: "Add proper waits between navigations: wait for page load before navigating again.",
|
|
4114
|
+
errorCode: -32100,
|
|
4115
|
+
errorMessage: "[CDP Firewall] Rapid navigation sequence detected"
|
|
4116
|
+
};
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
state.lastNavTime = now;
|
|
4120
|
+
return null;
|
|
4121
|
+
}
|
|
4122
|
+
case "Page.captureScreenshot": {
|
|
4123
|
+
state.screenshots++;
|
|
4124
|
+
if (state.lastNavTime > 0 && now - state.lastNavTime < 500) {
|
|
4125
|
+
return {
|
|
4126
|
+
ruleId: "page-lifecycle",
|
|
4127
|
+
action: "pass",
|
|
4128
|
+
severity: "danger",
|
|
4129
|
+
reason: "Page.captureScreenshot called within 500ms of navigation \u2014 content extraction pattern.",
|
|
4130
|
+
suggestion: "Wait for the page to fully render before taking screenshots: wait for load/networkidle.",
|
|
4131
|
+
errorCode: -32101,
|
|
4132
|
+
errorMessage: "[CDP Firewall] Pre-render screenshot blocked \u2014 content extraction pattern"
|
|
4133
|
+
};
|
|
4134
|
+
}
|
|
4135
|
+
if (state.screenshots > 3 && state.navigations.length < 2) {
|
|
4136
|
+
return {
|
|
4137
|
+
ruleId: "page-lifecycle",
|
|
4138
|
+
action: "pass",
|
|
4139
|
+
severity: "warn",
|
|
4140
|
+
reason: "Multiple screenshots on a single page without navigation \u2014 suspicious extraction behavior.",
|
|
4141
|
+
suggestion: "Consider if all screenshots are necessary.",
|
|
4142
|
+
errorCode: -32102,
|
|
4143
|
+
errorMessage: "[CDP Firewall] Excessive screenshot detection"
|
|
4144
|
+
};
|
|
4145
|
+
}
|
|
4146
|
+
return null;
|
|
4147
|
+
}
|
|
4148
|
+
case "Page.printToPDF": {
|
|
4149
|
+
return {
|
|
4150
|
+
ruleId: "page-lifecycle",
|
|
4151
|
+
action: "pass",
|
|
4152
|
+
severity: "danger",
|
|
4153
|
+
reason: "Page.printToPDF called \u2014 this is a telltale scraper pattern that gives away automation intent.",
|
|
4154
|
+
suggestion: "Avoid PDF generation. If you must, add significant delays and user-like interaction first.",
|
|
4155
|
+
errorCode: -32103,
|
|
4156
|
+
errorMessage: "[CDP Firewall] printToPDF blocked \u2014 scraper intent detected"
|
|
4157
|
+
};
|
|
4158
|
+
}
|
|
4159
|
+
case "Page.reload": {
|
|
4160
|
+
if (state.lastNavTime > 0 && now - state.lastNavTime < 1e3) {
|
|
4161
|
+
return {
|
|
4162
|
+
ruleId: "page-lifecycle",
|
|
4163
|
+
action: "pass",
|
|
4164
|
+
severity: "warn",
|
|
4165
|
+
reason: "Page.reload called immediately after navigate \u2014 unnatural fast-reload pattern.",
|
|
4166
|
+
suggestion: "Introduce delays between navigation and reload to simulate human behavior.",
|
|
4167
|
+
errorCode: -32104,
|
|
4168
|
+
errorMessage: "[CDP Firewall] Rapid reload detected"
|
|
4169
|
+
};
|
|
4170
|
+
}
|
|
4171
|
+
return null;
|
|
4172
|
+
}
|
|
4173
|
+
case "Page.close": {
|
|
4174
|
+
if (state.navigations.length < 2) {
|
|
4175
|
+
return {
|
|
4176
|
+
ruleId: "page-lifecycle",
|
|
4177
|
+
action: "pass",
|
|
4178
|
+
severity: "info",
|
|
4179
|
+
reason: "Page.close called after minimal interaction \u2014 zombie pages common in automation.",
|
|
4180
|
+
suggestion: "Ensure meaningful interaction before closing pages.",
|
|
4181
|
+
errorCode: -32105,
|
|
4182
|
+
errorMessage: "[CDP Firewall] Page close after minimal interaction"
|
|
4183
|
+
};
|
|
4184
|
+
}
|
|
4185
|
+
return null;
|
|
4186
|
+
}
|
|
4187
|
+
case "Runtime.evaluate":
|
|
4188
|
+
case "Runtime.callFunctionOn": {
|
|
4189
|
+
state.evaluateCount++;
|
|
4190
|
+
if (state.evaluateCount > 50 && state.navigations.length === 0) {
|
|
4191
|
+
return {
|
|
4192
|
+
ruleId: "page-lifecycle",
|
|
4193
|
+
action: "pass",
|
|
4194
|
+
severity: "info",
|
|
4195
|
+
reason: "50+ evaluate calls without any Page.navigate \u2014 data extraction without real browsing.",
|
|
4196
|
+
suggestion: "Navigate to a real page first. Evaluate on about:blank is suspicious.",
|
|
4197
|
+
errorCode: -32106,
|
|
4198
|
+
errorMessage: "[CDP Firewall] Excessive evaluate without navigation"
|
|
4199
|
+
};
|
|
4200
|
+
}
|
|
4201
|
+
return null;
|
|
4202
|
+
}
|
|
4203
|
+
default:
|
|
4204
|
+
return null;
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
};
|
|
4208
|
+
|
|
4209
|
+
// src/cdp-interceptor/rules-engine.ts
|
|
4210
|
+
var BUILTIN_RULES = [
|
|
4211
|
+
domMutationRule,
|
|
4212
|
+
automationSignalsRule,
|
|
4213
|
+
fingerprintingRule,
|
|
4214
|
+
eventSimulationRule,
|
|
4215
|
+
mouseTrajectoryRule,
|
|
4216
|
+
inputKeystrokeRule,
|
|
4217
|
+
emulationOverrideRule,
|
|
4218
|
+
networkAnomalyRule,
|
|
4219
|
+
pageLifecycleRule
|
|
4220
|
+
];
|
|
4221
|
+
function createRuleEngine(customRules) {
|
|
4222
|
+
const rules = [...BUILTIN_RULES, ...customRules ?? []].sort((a, b) => a.priority - b.priority);
|
|
4223
|
+
const sessionStates = /* @__PURE__ */ new Map();
|
|
4224
|
+
function getSessionState(sessionId) {
|
|
4225
|
+
let state = sessionStates.get(sessionId);
|
|
4226
|
+
if (!state) {
|
|
4227
|
+
state = /* @__PURE__ */ new Map();
|
|
4228
|
+
sessionStates.set(sessionId, state);
|
|
4229
|
+
}
|
|
4230
|
+
return state;
|
|
4231
|
+
}
|
|
4232
|
+
return {
|
|
4233
|
+
start() {
|
|
4234
|
+
sessionStates.clear();
|
|
4235
|
+
},
|
|
4236
|
+
stop() {
|
|
4237
|
+
sessionStates.clear();
|
|
4238
|
+
},
|
|
4239
|
+
evaluate(ctx) {
|
|
4240
|
+
const fullCtx = {
|
|
4241
|
+
...ctx,
|
|
4242
|
+
sessionState: getSessionState(ctx.sessionId)
|
|
4243
|
+
};
|
|
4244
|
+
for (const rule of rules) {
|
|
4245
|
+
if (rule.canHandle && !rule.canHandle(fullCtx)) continue;
|
|
4246
|
+
const decision = rule.evaluate(fullCtx);
|
|
4247
|
+
if (!decision) continue;
|
|
4248
|
+
if (decision.action !== "pass") return decision;
|
|
4249
|
+
}
|
|
4250
|
+
return null;
|
|
4251
|
+
}
|
|
4252
|
+
};
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
// src/cdp-driver/connection.ts
|
|
4256
|
+
var CDPConnection = class _CDPConnection extends EventEmitter4 {
|
|
4257
|
+
ws;
|
|
4258
|
+
nextId = 1;
|
|
4259
|
+
/** CDP Guard:全部出站命令过规则引擎(env XBROWSER_CDP_GUARD=off 关闭)。
|
|
4260
|
+
* 173 条规则 / 9 模块:合成事件 block、指纹暴露警告、自动化信号拦截。 */
|
|
4261
|
+
static __guard;
|
|
4262
|
+
get guard() {
|
|
4263
|
+
if (_CDPConnection.__guard === void 0) {
|
|
4264
|
+
if (process.env.XBROWSER_CDP_GUARD === "off") {
|
|
4265
|
+
_CDPConnection.__guard = null;
|
|
4266
|
+
} else {
|
|
4267
|
+
try {
|
|
4268
|
+
const g = createRuleEngine();
|
|
4269
|
+
g.start();
|
|
4270
|
+
_CDPConnection.__guard = g;
|
|
4271
|
+
} catch {
|
|
4272
|
+
_CDPConnection.__guard = null;
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
return _CDPConnection.__guard;
|
|
4277
|
+
}
|
|
4278
|
+
pending = /* @__PURE__ */ new Map();
|
|
4279
|
+
closed = false;
|
|
4280
|
+
closeReason = null;
|
|
4281
|
+
/** Default session ID for flat session protocol (Target.attachToTarget) */
|
|
4282
|
+
defaultSessionId;
|
|
4283
|
+
constructor(wsOrUrl, sessionId) {
|
|
4284
|
+
super();
|
|
4285
|
+
this.setMaxListeners(0);
|
|
4286
|
+
this.defaultSessionId = sessionId;
|
|
4287
|
+
if (typeof wsOrUrl === "string") {
|
|
4288
|
+
const wsOptions = /^wss:\/\/\d+\.\d+\.\d+\.\d+/.test(wsOrUrl) ? { rejectUnauthorized: false } : void 0;
|
|
4289
|
+
this.ws = new WebSocket(wsOrUrl, wsOptions);
|
|
4290
|
+
} else {
|
|
4291
|
+
this.ws = wsOrUrl;
|
|
4292
|
+
}
|
|
4293
|
+
this.bindWebSocket();
|
|
4294
|
+
this.startKeepalive();
|
|
4295
|
+
}
|
|
4296
|
+
/** Send periodic WS pings to prevent idle-timeout disconnects (e.g. CF's 100s).
|
|
4297
|
+
* Also detects dead connections: if a pong isn't received within 10s of a
|
|
4298
|
+
* ping, the connection is considered dead and forcibly closed. */
|
|
4299
|
+
keepaliveTimer = null;
|
|
4300
|
+
pongTimer = null;
|
|
4301
|
+
/** 最近一次收到任何 WS 消息的时间——有数据流动即证明连接存活 */
|
|
4302
|
+
lastIncomingAt = Date.now();
|
|
4303
|
+
startKeepalive() {
|
|
4304
|
+
this.ws.on("pong", () => {
|
|
4305
|
+
if (this.pongTimer) {
|
|
4306
|
+
clearTimeout(this.pongTimer);
|
|
4307
|
+
this.pongTimer = null;
|
|
4308
|
+
}
|
|
4309
|
+
});
|
|
4310
|
+
this.keepaliveTimer = setInterval(() => {
|
|
4311
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
4312
|
+
if (!this.pongTimer) {
|
|
4313
|
+
this.pongTimer = setTimeout(() => {
|
|
4314
|
+
const recentlyActive = Date.now() - this.lastIncomingAt < 3e4;
|
|
4315
|
+
if (recentlyActive) {
|
|
4316
|
+
this.pongTimer = null;
|
|
4317
|
+
return;
|
|
4318
|
+
}
|
|
4319
|
+
if (!this.closed) {
|
|
4320
|
+
this.closed = true;
|
|
4321
|
+
this.closeReason = "keepalive timeout (no pong and idle >30s)";
|
|
4322
|
+
try {
|
|
4323
|
+
this.ws.terminate();
|
|
4324
|
+
} catch {
|
|
4325
|
+
}
|
|
4326
|
+
for (const [, pending] of this.pending) {
|
|
4327
|
+
clearTimeout(pending.timeout);
|
|
4328
|
+
pending.reject(new Error("Connection dead: keepalive timeout"));
|
|
4329
|
+
}
|
|
4330
|
+
this.pending.clear();
|
|
4331
|
+
this.emit("disconnect");
|
|
4332
|
+
}
|
|
4333
|
+
}, 6e4);
|
|
4334
|
+
}
|
|
4335
|
+
this.ws.ping?.();
|
|
4336
|
+
} else if (this.closed) {
|
|
4337
|
+
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
|
|
4338
|
+
this.keepaliveTimer = null;
|
|
4339
|
+
}
|
|
4340
|
+
}, 3e4);
|
|
4341
|
+
}
|
|
4342
|
+
/** Wait for the connection to be fully open */
|
|
4343
|
+
async ready() {
|
|
4344
|
+
if (this.ws.readyState === WebSocket.OPEN) return;
|
|
4345
|
+
if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
|
4346
|
+
throw new Error(`WebSocket already closed: ${this.closeReason ?? "unknown"}`);
|
|
4347
|
+
}
|
|
4348
|
+
return new Promise((resolve, reject) => {
|
|
4349
|
+
const onOpen = () => {
|
|
4350
|
+
this.ws.off("error", onError);
|
|
4351
|
+
resolve();
|
|
4352
|
+
};
|
|
4353
|
+
const onError = (err) => {
|
|
4354
|
+
this.ws.off("open", onOpen);
|
|
4355
|
+
reject(err);
|
|
4356
|
+
};
|
|
4357
|
+
this.ws.once("open", onOpen);
|
|
4358
|
+
this.ws.once("error", onError);
|
|
4359
|
+
});
|
|
4360
|
+
}
|
|
4361
|
+
/** Is the underlying WebSocket alive? */
|
|
4362
|
+
get isOpen() {
|
|
4363
|
+
return !this.closed && this.ws.readyState === WebSocket.OPEN;
|
|
4364
|
+
}
|
|
4365
|
+
/**
|
|
4366
|
+
* Send a CDP command and await its response.
|
|
4367
|
+
*
|
|
4368
|
+
* @param method — CDP domain.method (e.g. "Page.navigate")
|
|
4369
|
+
* @param params — method parameters
|
|
4370
|
+
* @param sessionId — optional flat session ID for sub-targets
|
|
4371
|
+
* @param timeoutMs — response timeout (default: 30s;重 SPA 加载期间主线程忙,
|
|
4372
|
+
* evaluate 可排队 >30s——Runtime.evaluate 单独放宽到 90s)
|
|
4373
|
+
* @returns the `result` field from the CDP response
|
|
4374
|
+
*/
|
|
4375
|
+
async send(method, params, sessionId, timeoutMs) {
|
|
4376
|
+
const effectiveTimeout = timeoutMs ?? (method === "Runtime.evaluate" ? 9e4 : 3e4);
|
|
4377
|
+
if (this.guard) {
|
|
4378
|
+
const decision = this.guard.evaluate({
|
|
4379
|
+
method,
|
|
4380
|
+
params: params ?? {},
|
|
4381
|
+
sessionId: sessionId ?? this.defaultSessionId ?? "",
|
|
4382
|
+
direction: "client\u2192browser"
|
|
4383
|
+
});
|
|
4384
|
+
if (decision && decision.action === "block") {
|
|
4385
|
+
throw new Error(`[CDP-Guard] ${decision.reason}${decision.suggestion ? " | \u66FF\u4EE3: " + decision.suggestion : ""} [${decision.ruleId}]`);
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
if (this.closed) {
|
|
4389
|
+
throw new Error(`CDP connection closed: ${this.closeReason ?? "unknown"}`);
|
|
4390
|
+
}
|
|
4391
|
+
if (!this.isOpen) {
|
|
4392
|
+
throw new Error(`CDP connection not open (state: ${this.ws.readyState})`);
|
|
4393
|
+
}
|
|
4394
|
+
const id = this.nextId++;
|
|
4395
|
+
const sid = sessionId ?? this.defaultSessionId;
|
|
4396
|
+
const message = { id, method };
|
|
4397
|
+
if (params !== void 0) message.params = params;
|
|
4398
|
+
if (sid !== void 0) message.sessionId = sid;
|
|
4399
|
+
return new Promise((resolve, reject) => {
|
|
4400
|
+
const timeout = setTimeout(() => {
|
|
4401
|
+
this.pending.delete(id);
|
|
4402
|
+
reject(new Error(`CDP timeout: ${method} (${effectiveTimeout}ms)`));
|
|
4403
|
+
}, effectiveTimeout);
|
|
4404
|
+
this.pending.set(id, {
|
|
4405
|
+
resolve: (v) => {
|
|
4406
|
+
clearTimeout(timeout);
|
|
4407
|
+
this.pending.delete(id);
|
|
4408
|
+
resolve(v);
|
|
4409
|
+
},
|
|
4410
|
+
reject: (err) => {
|
|
4411
|
+
clearTimeout(timeout);
|
|
4412
|
+
this.pending.delete(id);
|
|
4413
|
+
reject(err);
|
|
4414
|
+
},
|
|
4415
|
+
method,
|
|
4416
|
+
timeout
|
|
4417
|
+
});
|
|
4418
|
+
const data = JSON.stringify(message);
|
|
4419
|
+
try {
|
|
4420
|
+
this.ws.send(data);
|
|
4421
|
+
} catch (err) {
|
|
4422
|
+
clearTimeout(timeout);
|
|
4423
|
+
this.pending.delete(id);
|
|
4424
|
+
reject(new Error(`CDP send failed: ${method} \u2014 ${err instanceof Error ? err.message : String(err)}`));
|
|
4425
|
+
}
|
|
4426
|
+
});
|
|
4427
|
+
}
|
|
4428
|
+
/**
|
|
4429
|
+
* Subscribe to a CDP event.
|
|
4430
|
+
*
|
|
4431
|
+
* @param event — full event name (e.g. "Page.frameNavigated")
|
|
4432
|
+
* @param handler — called with the event params
|
|
4433
|
+
* @param sessionId — optional session filter
|
|
4434
|
+
*/
|
|
4435
|
+
on(event, handler) {
|
|
4436
|
+
return super.on(event, handler);
|
|
4437
|
+
}
|
|
4438
|
+
once(event, handler) {
|
|
4439
|
+
return super.once(event, handler);
|
|
4440
|
+
}
|
|
4441
|
+
/** Remove an event listener */
|
|
4442
|
+
off(event, handler) {
|
|
4443
|
+
super.off(event, handler);
|
|
4444
|
+
return this;
|
|
4445
|
+
}
|
|
4446
|
+
/**
|
|
4447
|
+
* Subscribe to a CDP event for a specific session.
|
|
4448
|
+
* Returns an unsubscribe function.
|
|
4449
|
+
*/
|
|
4450
|
+
subscribe(event, sessionId, handler) {
|
|
4451
|
+
const wrapper = (params, sid) => {
|
|
4452
|
+
if (sid === sessionId || !sessionId && !sid) handler(params);
|
|
4453
|
+
};
|
|
4454
|
+
this.on(event, wrapper);
|
|
4455
|
+
return () => this.off(event, wrapper);
|
|
4456
|
+
}
|
|
4457
|
+
/** Close the WebSocket */
|
|
4458
|
+
async close() {
|
|
4459
|
+
if (this.closed) return;
|
|
4460
|
+
this.closed = true;
|
|
4461
|
+
this.closeReason = "closed by caller";
|
|
4462
|
+
if (this.keepaliveTimer) {
|
|
4463
|
+
clearInterval(this.keepaliveTimer);
|
|
4464
|
+
this.keepaliveTimer = null;
|
|
4225
4465
|
}
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4466
|
+
if (this.pongTimer) {
|
|
4467
|
+
clearTimeout(this.pongTimer);
|
|
4468
|
+
this.pongTimer = null;
|
|
4469
|
+
}
|
|
4470
|
+
for (const [id, pending] of this.pending) {
|
|
4471
|
+
clearTimeout(pending.timeout);
|
|
4472
|
+
pending.reject(new Error(`Connection closed: ${pending.method}`));
|
|
4473
|
+
this.pending.delete(id);
|
|
4474
|
+
}
|
|
4475
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
4476
|
+
this.ws.close(1e3, "normal closure");
|
|
4477
|
+
}
|
|
4478
|
+
}
|
|
4479
|
+
/** Set the default session ID for flat protocol */
|
|
4480
|
+
setDefaultSessionId(sid) {
|
|
4481
|
+
this.defaultSessionId = sid;
|
|
4482
|
+
}
|
|
4483
|
+
// ── Private ─────────────────────────────────────────────────
|
|
4484
|
+
bindWebSocket() {
|
|
4485
|
+
this.ws.on("message", (raw) => {
|
|
4486
|
+
this.lastIncomingAt = Date.now();
|
|
4487
|
+
let msg;
|
|
4488
|
+
try {
|
|
4489
|
+
msg = JSON.parse(raw.toString());
|
|
4490
|
+
} catch {
|
|
4491
|
+
return;
|
|
4247
4492
|
}
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
if (
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
reason: "Page.captureScreenshot called within 500ms of navigation \u2014 content extraction pattern.",
|
|
4256
|
-
suggestion: "Wait for the page to fully render before taking screenshots: wait for load/networkidle.",
|
|
4257
|
-
errorCode: -32101,
|
|
4258
|
-
errorMessage: "[CDP Firewall] Pre-render screenshot blocked \u2014 content extraction pattern"
|
|
4259
|
-
};
|
|
4260
|
-
}
|
|
4261
|
-
if (state.screenshots > 3 && state.navigations.length < 2) {
|
|
4262
|
-
return {
|
|
4263
|
-
ruleId: "page-lifecycle",
|
|
4264
|
-
action: "block",
|
|
4265
|
-
severity: "warn",
|
|
4266
|
-
reason: "Multiple screenshots on a single page without navigation \u2014 suspicious extraction behavior.",
|
|
4267
|
-
suggestion: "Consider if all screenshots are necessary.",
|
|
4268
|
-
errorCode: -32102,
|
|
4269
|
-
errorMessage: "[CDP Firewall] Excessive screenshot detection"
|
|
4270
|
-
};
|
|
4493
|
+
if (msg.id !== void 0) {
|
|
4494
|
+
const pending = this.pending.get(msg.id);
|
|
4495
|
+
if (!pending) return;
|
|
4496
|
+
if (msg.error) {
|
|
4497
|
+
pending.reject(new CDPProtocolError(msg.error.code, msg.error.message, pending.method));
|
|
4498
|
+
} else {
|
|
4499
|
+
pending.resolve(msg.result ?? {});
|
|
4271
4500
|
}
|
|
4272
|
-
return
|
|
4273
|
-
}
|
|
4274
|
-
case "Page.printToPDF": {
|
|
4275
|
-
return {
|
|
4276
|
-
ruleId: "page-lifecycle",
|
|
4277
|
-
action: "block",
|
|
4278
|
-
severity: "danger",
|
|
4279
|
-
reason: "Page.printToPDF called \u2014 this is a telltale scraper pattern that gives away automation intent.",
|
|
4280
|
-
suggestion: "Avoid PDF generation. If you must, add significant delays and user-like interaction first.",
|
|
4281
|
-
errorCode: -32103,
|
|
4282
|
-
errorMessage: "[CDP Firewall] printToPDF blocked \u2014 scraper intent detected"
|
|
4283
|
-
};
|
|
4501
|
+
return;
|
|
4284
4502
|
}
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
ruleId: "page-lifecycle",
|
|
4289
|
-
action: "block",
|
|
4290
|
-
severity: "warn",
|
|
4291
|
-
reason: "Page.reload called immediately after navigate \u2014 unnatural fast-reload pattern.",
|
|
4292
|
-
suggestion: "Introduce delays between navigation and reload to simulate human behavior.",
|
|
4293
|
-
errorCode: -32104,
|
|
4294
|
-
errorMessage: "[CDP Firewall] Rapid reload detected"
|
|
4295
|
-
};
|
|
4296
|
-
}
|
|
4297
|
-
return null;
|
|
4503
|
+
if (msg.method) {
|
|
4504
|
+
this.emit(msg.method, msg.params ?? {}, msg.sessionId);
|
|
4505
|
+
this.emit("*", msg.method, msg.params ?? {}, msg.sessionId);
|
|
4298
4506
|
}
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
errorCode: -32105,
|
|
4308
|
-
errorMessage: "[CDP Firewall] Page close after minimal interaction"
|
|
4309
|
-
};
|
|
4310
|
-
}
|
|
4311
|
-
return null;
|
|
4507
|
+
});
|
|
4508
|
+
this.ws.on("close", (code, reason) => {
|
|
4509
|
+
if (this.closed) return;
|
|
4510
|
+
this.closed = true;
|
|
4511
|
+
this.closeReason = `WebSocket closed: ${code} ${reason?.toString() ?? ""}`.trim();
|
|
4512
|
+
if (this.keepaliveTimer) {
|
|
4513
|
+
clearInterval(this.keepaliveTimer);
|
|
4514
|
+
this.keepaliveTimer = null;
|
|
4312
4515
|
}
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
return {
|
|
4318
|
-
ruleId: "page-lifecycle",
|
|
4319
|
-
action: "block",
|
|
4320
|
-
severity: "info",
|
|
4321
|
-
reason: "50+ evaluate calls without any Page.navigate \u2014 data extraction without real browsing.",
|
|
4322
|
-
suggestion: "Navigate to a real page first. Evaluate on about:blank is suspicious.",
|
|
4323
|
-
errorCode: -32106,
|
|
4324
|
-
errorMessage: "[CDP Firewall] Excessive evaluate without navigation"
|
|
4325
|
-
};
|
|
4326
|
-
}
|
|
4327
|
-
return null;
|
|
4516
|
+
for (const [id, pending] of this.pending) {
|
|
4517
|
+
clearTimeout(pending.timeout);
|
|
4518
|
+
pending.reject(new Error(`Connection closed: ${pending.method}`));
|
|
4519
|
+
this.pending.delete(id);
|
|
4328
4520
|
}
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4521
|
+
this.emit("disconnect");
|
|
4522
|
+
});
|
|
4523
|
+
this.ws.on("error", (err) => {
|
|
4524
|
+
if (this.closed) return;
|
|
4525
|
+
this.emit("ws-error", err);
|
|
4526
|
+
});
|
|
4527
|
+
}
|
|
4528
|
+
};
|
|
4529
|
+
var CDPProtocolError = class extends Error {
|
|
4530
|
+
code;
|
|
4531
|
+
method;
|
|
4532
|
+
data;
|
|
4533
|
+
constructor(code, message, method, data) {
|
|
4534
|
+
super(`CDP error [${code}] in ${method}: ${message}`);
|
|
4535
|
+
this.name = "CDPProtocolError";
|
|
4536
|
+
this.code = code;
|
|
4537
|
+
this.method = method;
|
|
4538
|
+
this.data = data;
|
|
4332
4539
|
}
|
|
4333
4540
|
};
|
|
4334
4541
|
|
|
4335
|
-
// src/cdp-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
sessionStates.set(sessionId, state);
|
|
4355
|
-
}
|
|
4356
|
-
return state;
|
|
4542
|
+
// src/cdp-driver/index.ts
|
|
4543
|
+
async function launch(options = {}) {
|
|
4544
|
+
let wsEndpoint;
|
|
4545
|
+
let childProcess;
|
|
4546
|
+
let tmpDir;
|
|
4547
|
+
if (options.cdpEndpoint) {
|
|
4548
|
+
wsEndpoint = await connectToCDP(options.cdpEndpoint);
|
|
4549
|
+
} else {
|
|
4550
|
+
const result = await launchChrome({
|
|
4551
|
+
executablePath: options.executablePath,
|
|
4552
|
+
headless: options.headless,
|
|
4553
|
+
args: options.args,
|
|
4554
|
+
userDataDir: options.userDataDir,
|
|
4555
|
+
timeout: options.timeout,
|
|
4556
|
+
env: options.env
|
|
4557
|
+
});
|
|
4558
|
+
wsEndpoint = result.wsEndpoint;
|
|
4559
|
+
childProcess = result.process;
|
|
4560
|
+
tmpDir = result.tmpDir;
|
|
4357
4561
|
}
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
sessionStates.clear();
|
|
4364
|
-
},
|
|
4365
|
-
evaluate(ctx) {
|
|
4366
|
-
const fullCtx = {
|
|
4367
|
-
...ctx,
|
|
4368
|
-
sessionState: getSessionState(ctx.sessionId)
|
|
4369
|
-
};
|
|
4370
|
-
for (const rule of rules) {
|
|
4371
|
-
if (rule.canHandle && !rule.canHandle(fullCtx)) continue;
|
|
4372
|
-
const decision = rule.evaluate(fullCtx);
|
|
4373
|
-
if (!decision) continue;
|
|
4374
|
-
if (decision.action !== "pass") return decision;
|
|
4375
|
-
}
|
|
4376
|
-
return null;
|
|
4377
|
-
}
|
|
4378
|
-
};
|
|
4562
|
+
const conn = new CDPConnection(wsEndpoint);
|
|
4563
|
+
await conn.ready();
|
|
4564
|
+
const httpEndpoint = options.cdpEndpoint && !options.cdpEndpoint.startsWith("ws") ? options.cdpEndpoint : void 0;
|
|
4565
|
+
const browser = new XBBrowserImpl(conn, childProcess, tmpDir, httpEndpoint);
|
|
4566
|
+
return { browser, wsEndpoint };
|
|
4379
4567
|
}
|
|
4380
4568
|
|
|
4569
|
+
// src/cdp-interceptor/proxy.ts
|
|
4570
|
+
import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
|
|
4571
|
+
|
|
4381
4572
|
// src/cdp-interceptor/logger.ts
|
|
4382
4573
|
function createLogger(config) {
|
|
4383
4574
|
const buffer = [];
|