@xbrowser/cli 1.11.0 → 1.13.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-XNU7JQYC.js → browser-AQ2AT2YZ.js} +2 -2
- package/dist/{browser-Q5APBNF6.js → browser-OMGJMSBB.js} +1 -1
- package/dist/{browser-V3JIWSTR.js → browser-T7OVLAEC.js} +4 -3
- package/dist/{cdp-driver-XFTTZI5O.js → cdp-driver-ALDNOZYD.js} +1176 -13
- package/dist/{cdp-driver-GD6YBBGE.js → cdp-driver-HYCLE4YP.js} +2 -1
- package/dist/{cdp-driver-VEK6BNN6.js → cdp-driver-VS7Z7W7Y.js} +1 -1
- package/dist/chunk-A6LPGFAL.js +437 -0
- package/dist/{chunk-JEDP4PJW.js → chunk-BY5TT6WZ.js} +912 -688
- package/dist/{chunk-XKQVUFUS.js → chunk-CW7L53JE.js} +2 -2
- package/dist/{chunk-ABXMBNQ6.js → chunk-H2A5JUK5.js} +37 -466
- package/dist/{chunk-BNO7OKO4.js → chunk-HBMEFSTB.js} +39 -0
- package/dist/chunk-HD4FX2N5.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-7OFM755Z.js → chunk-R2POF2GP.js} +236 -13
- package/dist/{chunk-WHPBNUKB.js → chunk-SH6OTPXN.js} +42 -46
- package/dist/{chunk-XQ4HRPDJ.js → chunk-T2CNKWPV.js} +1177 -13
- package/dist/{chunk-MWFHZUIY.js → chunk-TEXCXIBW.js} +1 -1
- 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-UVH5FGY2.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
|
" }",
|
|
@@ -149,7 +149,160 @@ function buildStealthInitScript() {
|
|
|
149
149
|
' Object.defineProperty(Screen.prototype,"height",{get:_gh,configurable:true});',
|
|
150
150
|
' Object.defineProperty(Screen.prototype,"availWidth",{get:_gw,configurable:true});',
|
|
151
151
|
' Object.defineProperty(Screen.prototype,"availHeight",{get:_gah,configurable:true});',
|
|
152
|
-
|
|
152
|
+
// hasFocus 与 visibilityState 联动(d29):恒 true 在 hidden 标签下暴露
|
|
153
|
+
// —— 真实浏览器失焦/后台时 hasFocus=false
|
|
154
|
+
' document.hasFocus=function(){return document.visibilityState==="visible";};',
|
|
155
|
+
// 4. Canvas/WebGL fingerprint: per-session stable noise (d20).
|
|
156
|
+
// Headless software raster differs subtly from Chrome GPU raster —
|
|
157
|
+
// toDataURL hashes fingerprint the rasterizer. Inject a stable
|
|
158
|
+
// (per-page-load) subpixel shift into fillText so hashes look like a
|
|
159
|
+
// distinct-but-consistent real GPU, and hide the HEADLESS tell in
|
|
160
|
+
// WebGL renderer strings.
|
|
161
|
+
" var _seed=Math.floor(Math.random()*2147483647);",
|
|
162
|
+
" var _prng=function(){_seed=(_seed*48271)%2147483647;return _seed/2147483647;};",
|
|
163
|
+
" var _dx=(_prng()*0.4-0.2).toFixed(3)*1, _dy=(_prng()*0.4-0.2).toFixed(3)*1;",
|
|
164
|
+
" var _fillText=CanvasRenderingContext2D.prototype.fillText;",
|
|
165
|
+
" CanvasRenderingContext2D.prototype.fillText=function(t,x,y,m){",
|
|
166
|
+
" return _fillText.call(this,t,x+_dx,y+_dy,m);",
|
|
167
|
+
" };",
|
|
168
|
+
" var _toDataURL=HTMLCanvasElement.prototype.toDataURL;",
|
|
169
|
+
" HTMLCanvasElement.prototype.toDataURL=function(){",
|
|
170
|
+
' var ctx=this.getContext("2d");',
|
|
171
|
+
" if(ctx){var d=ctx.getImageData(0,0,Math.min(this.width,2),Math.min(this.height,2));",
|
|
172
|
+
" for(var i=0;i<d.data.length;i+=4){if(d.data[i+3]>0){d.data[i]^=1;break;}}",
|
|
173
|
+
" ctx.putImageData(d,0,0);}",
|
|
174
|
+
" return _toDataURL.apply(this,arguments);",
|
|
175
|
+
" };",
|
|
176
|
+
" try{",
|
|
177
|
+
" var _gl=HTMLCanvasElement.prototype.getContext;",
|
|
178
|
+
" HTMLCanvasElement.prototype.getContext=function(t,o){",
|
|
179
|
+
" var c=_gl.call(this,t,o);",
|
|
180
|
+
' if(c&&(t==="webgl"||t==="experimental-webgl")&&c.getParameter){',
|
|
181
|
+
" var _gp=c.getParameter.bind(c);",
|
|
182
|
+
" c.getParameter=function(p){",
|
|
183
|
+
" var v=_gp(p);",
|
|
184
|
+
' if(typeof v==="string"&&/SwiftShader|Software|Rasterizer|Headless/i.test(v))',
|
|
185
|
+
' return "ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max, Unspecified Version)";',
|
|
186
|
+
" return v;};}",
|
|
187
|
+
" return c;};",
|
|
188
|
+
" }catch(e){}",
|
|
189
|
+
// 5. AudioContext fingerprint: per-load stable micro-noise on channel
|
|
190
|
+
// data (d21). DSP sum differences between headless software audio and
|
|
191
|
+
// real hardware audio are a classic fingerprint — add ±1e-7 level
|
|
192
|
+
// noise (inaudible, changes the sum hash).
|
|
193
|
+
" try{",
|
|
194
|
+
" var _gcd=AudioBuffer.prototype.getChannelData;",
|
|
195
|
+
" AudioBuffer.prototype.getChannelData=function(ch){",
|
|
196
|
+
" var d=_gcd.call(this,ch);",
|
|
197
|
+
' var key="__xb_audio_"+ch;',
|
|
198
|
+
" if(!this[key]){",
|
|
199
|
+
" this[key]=true;",
|
|
200
|
+
" for(var i=0;i<d.length;i+=997){d[i]=d[i]+(_prng()-0.5)*2e-7;}",
|
|
201
|
+
" }",
|
|
202
|
+
" return d;",
|
|
203
|
+
" };",
|
|
204
|
+
" var _gffd=AnalyserNode.prototype.getFloatFrequencyData;",
|
|
205
|
+
" AnalyserNode.prototype.getFloatFrequencyData=function(arr){",
|
|
206
|
+
" _gffd.call(this,arr);",
|
|
207
|
+
" for(var i=0;i<arr.length;i+=31){arr[i]=arr[i]+(_prng()-0.5)*0.01;}",
|
|
208
|
+
" };",
|
|
209
|
+
" var _gfbd=AnalyserNode.prototype.getByteFrequencyData;",
|
|
210
|
+
" AnalyserNode.prototype.getByteFrequencyData=function(arr){",
|
|
211
|
+
" _gfbd.call(this,arr);",
|
|
212
|
+
" for(var i=0;i<arr.length;i+=31){arr[i]=(arr[i]+((_prng()*3)|0))&255;}",
|
|
213
|
+
" };",
|
|
214
|
+
" }catch(e){}",
|
|
215
|
+
// 3b. Font metrics + speechSynthesis + battery (d22): headless tells
|
|
216
|
+
" try{",
|
|
217
|
+
" var _mt=CanvasRenderingContext2D.prototype.measureText;",
|
|
218
|
+
' var _tmw=Object.getOwnPropertyDescriptor(TextMetrics.prototype,"width");',
|
|
219
|
+
" CanvasRenderingContext2D.prototype.measureText=function(t){",
|
|
220
|
+
" var m=_mt.call(this,t);",
|
|
221
|
+
' try{Object.defineProperty(m,"width",{get:function(){return _tmw.get.call(m)+_dx*0.01;},configurable:true});}catch(e){}',
|
|
222
|
+
" return m;",
|
|
223
|
+
" };",
|
|
224
|
+
" }catch(e){}",
|
|
225
|
+
" try{",
|
|
226
|
+
' var _fakeVoices=["Alex","Daniel","Karen","Moira","Ralph","Samantha","Ting-Ting","Mei-Jia","Sinji","Yunda"];',
|
|
227
|
+
" var _sv=speechSynthesis.getVoices.bind(speechSynthesis);",
|
|
228
|
+
" speechSynthesis.getVoices=function(){",
|
|
229
|
+
" var real=_sv();",
|
|
230
|
+
" if(real&&real.length>50)return real;",
|
|
231
|
+
// 注意阈值从 0 改 50:iframe 原生约 10 个 —— 原阈值下 iframe 返回原生 10 个
|
|
232
|
+
// 而不是伪装 180(d33 暴露)。>50 = 主文档伪装列表已生效,其余返回伪装。
|
|
233
|
+
' 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};});',
|
|
234
|
+
" };",
|
|
235
|
+
// d33 修复:iframe 的 speechSynthesis 是独立实例(主文档 hook 不可达)——
|
|
236
|
+
// 轮询同源 iframe,对其 contentWindow.speechSynthesis 同样 patch
|
|
237
|
+
" try{",
|
|
238
|
+
" var _piv=function(){",
|
|
239
|
+
' var frs=document.querySelectorAll("iframe");',
|
|
240
|
+
" for(var i=0;i<frs.length;i++){try{",
|
|
241
|
+
" var cw=frs[i].contentWindow;",
|
|
242
|
+
" if(!cw||!cw.speechSynthesis||cw.speechSynthesis.__xbP)continue;",
|
|
243
|
+
" cw.speechSynthesis.__xbP=true;",
|
|
244
|
+
" var _sv2=cw.speechSynthesis.getVoices.bind(cw.speechSynthesis);",
|
|
245
|
+
" cw.speechSynthesis.getVoices=function(){var r=_sv2();if(r&&r.length>50)return r;",
|
|
246
|
+
' return _fakeVoices.map(function(n,j){return{name:n,lang:j<2?"en-US":j<6?"en-GB":"zh-TW",localService:true,default:j===0,voiceURI:n};});};',
|
|
247
|
+
" }catch(e2){}}",
|
|
248
|
+
" };",
|
|
249
|
+
" setInterval(_piv,2000);",
|
|
250
|
+
" var _mo=new MutationObserver(function(muts){",
|
|
251
|
+
" for(var k=0;k<muts.length;k++){var ns=muts[k].addedNodes;",
|
|
252
|
+
' for(var q=0;q<ns.length;q++){if(ns[q].tagName==="IFRAME")setTimeout(_piv,20);}}',
|
|
253
|
+
" });",
|
|
254
|
+
" _mo.observe(document.documentElement,{childList:true,subtree:true});",
|
|
255
|
+
" }catch(e){}",
|
|
256
|
+
" }catch(e){}",
|
|
257
|
+
" try{",
|
|
258
|
+
" var _gb=navigator.getBattery.bind(navigator);",
|
|
259
|
+
" navigator.getBattery=function(){",
|
|
260
|
+
" return _gb().then(function(b){",
|
|
261
|
+
' Object.defineProperty(b,"charging",{get:function(){return true;},configurable:true});',
|
|
262
|
+
" return b;});",
|
|
263
|
+
" };",
|
|
264
|
+
" }catch(e){}",
|
|
265
|
+
// 3c. WebRTC local IP leak guard (d23): headless STUN candidates can
|
|
266
|
+
// expose host IPs; mDNS-only candidates are the modern Chrome default.
|
|
267
|
+
" try{",
|
|
268
|
+
" var _oc=RTCPeerConnection.prototype.createOffer;",
|
|
269
|
+
" RTCPeerConnection.prototype.createOffer=function(){",
|
|
270
|
+
" var p=_oc.apply(this,arguments);",
|
|
271
|
+
" var self=this;",
|
|
272
|
+
" return p.then(function(offer){",
|
|
273
|
+
' offer.sdp=offer.sdp.split(String.fromCharCode(10)).filter(function(l){return l.indexOf("typ host")<0}).join(String.fromCharCode(10));',
|
|
274
|
+
" return offer;});",
|
|
275
|
+
" };",
|
|
276
|
+
" }catch(e){}",
|
|
277
|
+
// 3d-2. performance.now precision clamp (d34): full-precision timestamps
|
|
278
|
+
// (11 decimals) differ from site-isolation-clamped real Chrome (~100μs).
|
|
279
|
+
" try{",
|
|
280
|
+
" var _pn=performance.now.bind(performance);",
|
|
281
|
+
" performance.now=function(){return Math.round(_pn()*10)/10;};",
|
|
282
|
+
' var _pto=Object.getOwnPropertyDescriptor(Performance.prototype,"timeOrigin");',
|
|
283
|
+
' if(_pto&&_pto.get){Object.defineProperty(performance,"timeOrigin",{get:function(){return _pto.get.call(performance);},configurable:true});}',
|
|
284
|
+
" }catch(e){}",
|
|
285
|
+
// 3d. Chrome object depth (d24): automation fakes usually only set
|
|
286
|
+
// window.chrome = {}; deep checks hit app.run/runtime/csi/loadTimes.
|
|
287
|
+
" try{",
|
|
288
|
+
" if(window.chrome){",
|
|
289
|
+
' 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"}};',
|
|
290
|
+
' 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};',
|
|
291
|
+
" if(!window.chrome.csi)window.chrome.csi=function(){return{};}",
|
|
292
|
+
" if(!window.chrome.loadTimes)window.chrome.loadTimes=function(){return{};}",
|
|
293
|
+
" }",
|
|
294
|
+
" }catch(e){}",
|
|
295
|
+
// 3e. Font availability patch (d25): headless Chromium misses some system
|
|
296
|
+
// fonts (e.g. Menlo on macOS) that real Chrome has. Register a FontFace
|
|
297
|
+
// aliasing the missing font to a local() equivalent so offsetWidth-based
|
|
298
|
+
// font probing sees the same availability as the faked environment.
|
|
299
|
+
" try{",
|
|
300
|
+
' var _fontAliases=[["Menlo","Courier New"],["SF Mono","Menlo"],["Segoe UI","Helvetica"]];',
|
|
301
|
+
" _fontAliases.forEach(function(pa){",
|
|
302
|
+
' try{var ff=new FontFace(pa[0],"local("+JSON.stringify(pa[1])+")");',
|
|
303
|
+
" document.fonts.add(ff);ff.load();}catch(e2){}",
|
|
304
|
+
" });",
|
|
305
|
+
" }catch(e){}",
|
|
153
306
|
// 3. toString disguise (name-list based)
|
|
154
307
|
" var _ts=Function.prototype.toString;",
|
|
155
308
|
" var _hf=document.hasFocus;",
|
|
@@ -159,12 +312,16 @@ function buildStealthInitScript() {
|
|
|
159
312
|
' if(this===_gw)return"function get width() { [native code] }";',
|
|
160
313
|
' if(this===_gh)return"function get height() { [native code] }";',
|
|
161
314
|
' if(this===_gah)return"function get availHeight() { [native code] }";',
|
|
315
|
+
' if(this===CanvasRenderingContext2D.prototype.fillText)return"function fillText() { [native code] }";',
|
|
316
|
+
' if(this===HTMLCanvasElement.prototype.toDataURL)return"function toDataURL() { [native code] }";',
|
|
317
|
+
' if(this===AnalyserNode.prototype.getFloatFrequencyData)return"function getFloatFrequencyData() { [native code] }";',
|
|
318
|
+
' if(this===AudioBuffer.prototype.getChannelData)return"function getChannelData() { [native code] }";',
|
|
162
319
|
" return _ts.call(this);",
|
|
163
320
|
" };",
|
|
164
321
|
// 4. onclick prototype hijack (dual-stream consistency)
|
|
165
322
|
" var _ba=function(k,p){",
|
|
166
|
-
' if(p==="isTrusted")return
|
|
167
|
-
' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.
|
|
323
|
+
' if(p==="isTrusted")return k.isTrusted;',
|
|
324
|
+
' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.isInteger(k[p])&&k.isTrusted===true){',
|
|
168
325
|
" var _f=((k.timeStamp||Date.now())%89)/89*0.7+0.15;return k[p]+_f;",
|
|
169
326
|
" }",
|
|
170
327
|
' var v=Reflect.get(k,p);return typeof v==="function"?v.bind(k):v;',
|
|
@@ -200,7 +357,7 @@ var XBMouseImpl = class {
|
|
|
200
357
|
}
|
|
201
358
|
async click(x, y, opts = {}) {
|
|
202
359
|
const button = opts.button ?? "left";
|
|
203
|
-
const stealth = opts.stealth ??
|
|
360
|
+
const stealth = opts.stealth ?? process.env.XBROWSER_STEALTH !== "off";
|
|
204
361
|
let tx = x, ty = y;
|
|
205
362
|
if (stealth && opts.elementWidth !== void 0 && opts.elementHeight !== void 0) {
|
|
206
363
|
const off = landingOffset(opts.elementWidth, opts.elementHeight);
|
|
@@ -209,12 +366,23 @@ var XBMouseImpl = class {
|
|
|
209
366
|
}
|
|
210
367
|
if (stealth) {
|
|
211
368
|
const traj = bezierTrajectory(this._x, this._y, tx, ty);
|
|
369
|
+
const _tb = Date.now();
|
|
370
|
+
let _truncated = false;
|
|
212
371
|
for (const p of traj) {
|
|
372
|
+
if (Date.now() - _tb > 5e3) {
|
|
373
|
+
_truncated = true;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
213
376
|
await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: p.x, y: p.y, button: this._button });
|
|
214
377
|
this._x = p.x;
|
|
215
378
|
this._y = p.y;
|
|
216
379
|
await sleep(p.delay);
|
|
217
380
|
}
|
|
381
|
+
this._x = tx;
|
|
382
|
+
this._y = ty;
|
|
383
|
+
if (_truncated) {
|
|
384
|
+
await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: tx, y: ty, button: this._button });
|
|
385
|
+
}
|
|
218
386
|
await sleep(rand(...DEFAULT_STEALTH_CONFIG.aimPause));
|
|
219
387
|
} else {
|
|
220
388
|
await this.move(tx, ty);
|
|
@@ -319,12 +487,20 @@ var XBKeyboardImpl = class {
|
|
|
319
487
|
}
|
|
320
488
|
await this.dispatchKeyEvent(downParams);
|
|
321
489
|
if (mapping.text) {
|
|
490
|
+
if (process.env.XBROWSER_STEALTH !== "off") {
|
|
491
|
+
await sleep2(rand(...DEFAULT_STEALTH_CONFIG.keyPressDuration));
|
|
492
|
+
}
|
|
322
493
|
await this.dispatchKeyEvent({
|
|
323
494
|
type: "char",
|
|
324
495
|
text: mapping.text
|
|
325
496
|
});
|
|
326
497
|
}
|
|
327
498
|
if (delay > 0) await sleep2(delay);
|
|
499
|
+
else {
|
|
500
|
+
if (process.env.XBROWSER_STEALTH !== "off") {
|
|
501
|
+
await sleep2(rand(...DEFAULT_STEALTH_CONFIG.keyPressDuration));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
328
504
|
const upParams = {
|
|
329
505
|
type: "keyUp",
|
|
330
506
|
key: mapping.key,
|
|
@@ -2034,6 +2210,10 @@ Last error: ${lastError.message}` : "";
|
|
|
2034
2210
|
this.sessionId
|
|
2035
2211
|
);
|
|
2036
2212
|
}
|
|
2213
|
+
/** 覆盖 User-Agent(headless 环境对抗 UA 检测的标配能力) */
|
|
2214
|
+
async setUserAgent(userAgent) {
|
|
2215
|
+
await this._setUserAgent(userAgent);
|
|
2216
|
+
}
|
|
2037
2217
|
/** Internal: set extra HTTP headers */
|
|
2038
2218
|
async _setExtraHTTPHeaders(headers) {
|
|
2039
2219
|
await this.setExtraHTTPHeaders(headers);
|
|
@@ -3163,534 +3343,259 @@ var XBBrowserImpl = class {
|
|
|
3163
3343
|
// src/cdp-driver/connection.ts
|
|
3164
3344
|
import { EventEmitter as EventEmitter4 } from "events";
|
|
3165
3345
|
import { WebSocket } from "ws";
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3346
|
+
|
|
3347
|
+
// src/cdp-interceptor/rules/shared.ts
|
|
3348
|
+
var PLAYWRIGHT_INTERNAL_MARKERS = [
|
|
3349
|
+
"__commonJS",
|
|
3350
|
+
"module.exports",
|
|
3351
|
+
"__require",
|
|
3352
|
+
"__toESM",
|
|
3353
|
+
"inject_utils"
|
|
3354
|
+
];
|
|
3355
|
+
function isPlaywrightInternal(code) {
|
|
3356
|
+
return PLAYWRIGHT_INTERNAL_MARKERS.some((marker) => code.includes(marker));
|
|
3357
|
+
}
|
|
3358
|
+
function extractUserCode(ctx) {
|
|
3359
|
+
if (ctx.method === "Runtime.evaluate") {
|
|
3360
|
+
const expr = ctx.params.expression;
|
|
3361
|
+
if (typeof expr === "string") {
|
|
3362
|
+
if (isPlaywrightInternal(expr)) return null;
|
|
3363
|
+
return expr;
|
|
3183
3364
|
}
|
|
3184
|
-
this.bindWebSocket();
|
|
3185
|
-
this.startKeepalive();
|
|
3186
3365
|
}
|
|
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);
|
|
3366
|
+
if (ctx.method === "Runtime.callFunctionOn") {
|
|
3367
|
+
const decl = ctx.params.functionDeclaration;
|
|
3368
|
+
if (typeof decl === "string" && decl.includes("utilityScript.evaluate")) {
|
|
3369
|
+
return extractAllStrings(ctx.params.arguments);
|
|
3370
|
+
}
|
|
3371
|
+
if (typeof decl === "string") return decl;
|
|
3225
3372
|
}
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3373
|
+
return null;
|
|
3374
|
+
}
|
|
3375
|
+
function extractAllStrings(rawArgs) {
|
|
3376
|
+
if (!Array.isArray(rawArgs)) return null;
|
|
3377
|
+
const strings = [];
|
|
3378
|
+
for (const arg of rawArgs) {
|
|
3379
|
+
if (arg && typeof arg === "object" && "value" in arg) {
|
|
3380
|
+
const val = arg.value;
|
|
3381
|
+
if (typeof val === "string" && val.length > 5 && !["true", "false"].includes(val)) {
|
|
3382
|
+
strings.push(val);
|
|
3383
|
+
}
|
|
3231
3384
|
}
|
|
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
3385
|
}
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3386
|
+
return strings.length > 0 ? strings.join("\n") : null;
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// src/cdp-interceptor/rules/dom-mutation.ts
|
|
3390
|
+
var DOM_PATTERNS = [
|
|
3391
|
+
// ── P0: Value/Checked (bypasses React onChange) ────────────
|
|
3392
|
+
{ 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." },
|
|
3393
|
+
{ pattern: /\.checked\s*=\s*(?:true|false)/, name: ".checked =", severity: "danger", action: "pass", errorCode: -32001, suggestion: "Use page.check(selector) or page.uncheck(selector)." },
|
|
3394
|
+
{ pattern: /\.indeterminate\s*=\s*true/, name: ".indeterminate =", severity: "warn", action: "pass", errorCode: -32021, suggestion: "No human can set indeterminate state \u2014 remove this call." },
|
|
3395
|
+
{ pattern: /\.valueAsDate\s*=/, name: ".valueAsDate =", severity: "warn", action: "pass", errorCode: -32022, suggestion: "Use page.fill() with formatted date string instead." },
|
|
3396
|
+
{ pattern: /\.valueAsNumber\s*=/, name: ".valueAsNumber =", severity: "warn", action: "pass", errorCode: -32022, suggestion: "Use page.fill() with numeric string instead." },
|
|
3397
|
+
// ── P1: Select/Option (bypasses React onChange on select) ─
|
|
3398
|
+
{ pattern: /\.selectedIndex\s*=\s*\d+/, name: ".selectedIndex =", severity: "danger", action: "pass", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
|
|
3399
|
+
{ 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)." },
|
|
3400
|
+
{ pattern: /\.value\s*=\s*["'][^"']*["']\s*[;)]?\s*$/, name: "selectElement.value =", severity: "warn", action: "pass", errorCode: -32023, suggestion: "For select elements, use page.selectOption(selector, value)." },
|
|
3401
|
+
// ── P2: Content properties (bypasses virtual DOM diffing) ──
|
|
3402
|
+
{ pattern: /\.innerHTML\s*=/, name: ".innerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: ".innerHTML bypasses React/Vue diffing. Use component state or page.setContent()." },
|
|
3403
|
+
{ pattern: /\.outerHTML\s*=/, name: ".outerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: "outerHTML replacement destroys React fiber tree. Use page.setContent()." },
|
|
3404
|
+
{ pattern: /\.innerText\s*=/, name: ".innerText =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "Framework components should be updated via state, not innerText." },
|
|
3405
|
+
{ pattern: /\.textContent\s*=/, name: ".textContent =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "textContent bypasses React/Vue diffing. Use component state instead." },
|
|
3406
|
+
{ pattern: /\.outerText\s*=/, name: ".outerText =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "Non-standard property \u2014 use proper framework update methods." },
|
|
3407
|
+
{ pattern: /nodeValue\s*=/, name: "node.nodeValue =", severity: "info", action: "pass", errorCode: -32025, suggestion: "Direct text node mutation. Use textContent instead if needed." },
|
|
3408
|
+
// ── P3: Style properties ──────────────────────────────────
|
|
3409
|
+
{ 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".' },
|
|
3410
|
+
{ 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." },
|
|
3411
|
+
{ pattern: /\.style\.setProperty\s*\(/, name: ".style.setProperty()", severity: "info", action: "pass", errorCode: -32008, suggestion: "Direct style property set bypasses CSS transitions." },
|
|
3412
|
+
{ pattern: /\.style\.removeProperty\s*\(/, name: ".style.removeProperty()", severity: "info", action: "pass", errorCode: -32026, suggestion: "Style removal without user interaction is suspicious." },
|
|
3413
|
+
{ pattern: /\.style\.animation\s*=/, name: ".style.animation =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Forcing animation state via CDP is detectable." },
|
|
3414
|
+
{ pattern: /\.style\.transition\s*=/, name: ".style.transition =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Manipulating CSS transitions is rare in normal browsing." },
|
|
3415
|
+
// ── P4: Class/Attribute properties ────────────────────────
|
|
3416
|
+
{ pattern: /\.className\s*=/, name: ".className =", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or a Playwright locator instead." },
|
|
3417
|
+
{ pattern: /\.classList\.add\s*\(/, name: ".classList.add()", severity: "info", action: "pass", errorCode: -32009, suggestion: "classList manipulation via CDP bypasses framework state tracking." },
|
|
3418
|
+
{ pattern: /\.classList\.remove\s*\(/, name: ".classList.remove()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or attribute selectors instead." },
|
|
3419
|
+
{ pattern: /\.classList\.toggle\s*\(/, name: ".classList.toggle()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Toggle without user interaction is detectable." },
|
|
3420
|
+
{ pattern: /\.classList\.replace\s*\(/, name: ".classList.replace()", severity: "info", action: "pass", errorCode: -32028, suggestion: "Rare operation \u2014 likely automated." },
|
|
3421
|
+
// ── P5: Attribute manipulation ────────────────────────────
|
|
3422
|
+
{ pattern: /\.setAttribute\s*\(/, name: ".setAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "setAttribute bypasses framework attribute tracking. Use component state." },
|
|
3423
|
+
{ pattern: /\.removeAttribute\s*\(/, name: ".removeAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "Attribute removal without user interaction is suspicious." },
|
|
3424
|
+
{ pattern: /\.toggleAttribute\s*\(/, name: ".toggleAttribute()", severity: "info", action: "pass", errorCode: -32029, suggestion: "Attribute toggling via CDP is detectable." },
|
|
3425
|
+
{ pattern: /\.dataset\.\w+\s*=/, name: ".dataset.* =", severity: "info", action: "pass", errorCode: -32030, suggestion: "dataset mutations via evaluate bypass native mutation observers." },
|
|
3426
|
+
// ── P6: Focus/Selection properties ────────────────────────
|
|
3427
|
+
{ pattern: /\.selectionStart\s*=/, name: ".selectionStart =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Setting cursor position without focus is detectable." },
|
|
3428
|
+
{ pattern: /\.selectionEnd\s*=/, name: ".selectionEnd =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Selection range manipulation without user input is suspicious." },
|
|
3429
|
+
{ pattern: /\.selectionDirection\s*=/, name: ".selectionDirection =", severity: "info", action: "pass", errorCode: -32031, suggestion: "Selection direction changes normally via mouse drag." },
|
|
3430
|
+
// ── P7: Boolean properties ────────────────────────────────
|
|
3431
|
+
{ pattern: /\.disabled\s*=/, name: ".disabled =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Disabling elements via CDP mid-interaction is detectable." },
|
|
3432
|
+
{ pattern: /\.readOnly\s*=/, name: ".readOnly =", severity: "info", action: "pass", errorCode: -32032, suggestion: "readOnly changes without user action." },
|
|
3433
|
+
{ pattern: /\.hidden\s*=/, name: ".hidden =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Hiding elements is a common scraper tactic \u2014 detectable." },
|
|
3434
|
+
{ pattern: /\.required\s*=/, name: ".required =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Validation constraint changes mid-session." },
|
|
3435
|
+
{ pattern: /\.multiple\s*=/, name: ".multiple =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Multiple attribute toggle is rare in normal browsing." },
|
|
3436
|
+
{ pattern: /\.autofocus\s*=/, name: ".autofocus =", severity: "info", action: "pass", errorCode: -32032, suggestion: "autofocus changes mid-session are suspicious." },
|
|
3437
|
+
// ── P8: Frame/Navigation properties ───────────────────────
|
|
3438
|
+
{ 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." },
|
|
3439
|
+
{ pattern: /\.href\s*=/, name: ".href =", severity: "info", action: "pass", errorCode: -32033, suggestion: "Changing anchor href via evaluate \u2014 use page.click() instead." },
|
|
3440
|
+
{ pattern: /\.action\s*=/, name: "form.action =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Changing form action URL is highly suspicious." },
|
|
3441
|
+
{ pattern: /\.method\s*=/, name: "form.method =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Form method changes without user interaction." },
|
|
3442
|
+
{ pattern: /\.target\s*=/, name: "link/area.target =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Link target manipulation via CDP." },
|
|
3443
|
+
// ── P9: Media properties ──────────────────────────────────
|
|
3444
|
+
{ pattern: /\.currentTime\s*=/, name: "media.currentTime = (seeking)", severity: "info", action: "pass", errorCode: -32014, suggestion: "Video seeking without user interaction \u2014 common scraper pattern." },
|
|
3445
|
+
{ pattern: /\.playbackRate\s*=/, name: "media.playbackRate =", severity: "info", action: "pass", errorCode: -32014, suggestion: "Changing playback speed is detectable automation signal." },
|
|
3446
|
+
{ pattern: /\.volume\s*=/, name: "media.volume =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Volume setting via evaluate is suspicious." },
|
|
3447
|
+
{ pattern: /\.muted\s*=/, name: "media.muted =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Muting media without user click is suspicious." },
|
|
3448
|
+
// ── P10: Element geometry ─────────────────────────────────
|
|
3449
|
+
{ pattern: /\.scrollTop\s*=/, name: ".scrollTop =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Programmatic scroll without user gesture. Use page.mouse.wheel()." },
|
|
3450
|
+
{ pattern: /\.scrollLeft\s*=/, name: ".scrollLeft =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Horizontal scroll without user gesture." },
|
|
3451
|
+
{ pattern: /\.scrollTo\s*\(/, name: ".scrollTo()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollTo bypasses user scroll detection." },
|
|
3452
|
+
{ pattern: /\.scrollBy\s*\(/, name: ".scrollBy()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollBy without user gesture." },
|
|
3453
|
+
{ pattern: /\.scrollIntoView\s*\(/, name: ".scrollIntoView()", severity: "info", action: "pass", errorCode: -32015, suggestion: "scrollIntoView is a common bot pattern. Let Playwright handle scrolling." },
|
|
3454
|
+
// ── P11: Shadow DOM ───────────────────────────────────────
|
|
3455
|
+
{ pattern: /\.shadowRoot\s*=/, name: ".shadowRoot = (override)", severity: "info", action: "pass", errorCode: -32036, suggestion: "ShadowRoot is read-only \u2014 this set attempt is detectable." },
|
|
3456
|
+
// ── P12: Force reflow / layout thrashing ──────────────────
|
|
3457
|
+
{ 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." },
|
|
3458
|
+
{ pattern: /\.offsetWidth\b(?!\s*===?\s*)/, name: ".offsetWidth read (forced reflow)", severity: "warn", action: "pass", errorCode: -32016, suggestion: "Reading offsetWidth triggers forced reflow \u2014 detectable." },
|
|
3459
|
+
{ pattern: /getBoundingClientRect\s*\(/, name: "getBoundingClientRect()", severity: "warn", action: "pass", errorCode: -32037, suggestion: "getBoundingClientRect triggers reflow. Minimize calls." },
|
|
3460
|
+
{ pattern: /getComputedStyle\s*\(/, name: "getComputedStyle()", severity: "info", action: "pass", errorCode: -32038, suggestion: "getComputedStyle can trigger style recalculation." },
|
|
3461
|
+
{ pattern: /\.clientHeight\b/, name: ".clientHeight read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientHeight read triggers layout." },
|
|
3462
|
+
{ pattern: /\.clientWidth\b/, name: ".clientWidth read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientWidth read triggers layout." }
|
|
3463
|
+
];
|
|
3464
|
+
var domMutationRule = {
|
|
3465
|
+
id: "dom-mutation",
|
|
3466
|
+
name: "DOM Property Mutation Detection (50+ setters)",
|
|
3467
|
+
priority: 10,
|
|
3468
|
+
canHandle(ctx) {
|
|
3469
|
+
return ctx.method === "Runtime.evaluate" || ctx.method === "Runtime.callFunctionOn";
|
|
3470
|
+
},
|
|
3471
|
+
evaluate(ctx) {
|
|
3472
|
+
const userCode = extractUserCode(ctx);
|
|
3473
|
+
if (!userCode) return null;
|
|
3474
|
+
for (const p of DOM_PATTERNS) {
|
|
3475
|
+
if (p.pattern.test(userCode)) {
|
|
3476
|
+
return {
|
|
3477
|
+
ruleId: "dom-mutation",
|
|
3478
|
+
action: p.action,
|
|
3479
|
+
severity: p.severity,
|
|
3480
|
+
reason: `Direct DOM property setter: "${p.name}". This bypasses framework event systems and is detectable as automation.`,
|
|
3481
|
+
suggestion: p.suggestion,
|
|
3482
|
+
errorCode: p.errorCode,
|
|
3483
|
+
errorMessage: p.severity === "danger" ? `[CDP Firewall] ${p.name} blocked \u2014 bypasses framework reactivity` : `[CDP Firewall] ${p.name} detected \u2014 use proper interaction API`
|
|
3484
|
+
};
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
return null;
|
|
3248
3488
|
}
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3489
|
+
};
|
|
3490
|
+
|
|
3491
|
+
// src/cdp-interceptor/rules/mouse-trajectory.ts
|
|
3492
|
+
var TRACKER_KEY = "mouse-trajectory-tracker";
|
|
3493
|
+
var MAX_SAMPLES = 200;
|
|
3494
|
+
var MIN_SAMPLES_FOR_ANALYSIS = 5;
|
|
3495
|
+
var mouseTrajectoryRule = {
|
|
3496
|
+
id: "mouse-trajectory",
|
|
3497
|
+
name: "Mouse Trajectory Analysis",
|
|
3498
|
+
priority: 20,
|
|
3499
|
+
canHandle(ctx) {
|
|
3500
|
+
return ctx.method === "Input.dispatchMouseEvent";
|
|
3501
|
+
},
|
|
3502
|
+
evaluate(ctx) {
|
|
3503
|
+
const type = ctx.params.type;
|
|
3504
|
+
const x = ctx.params.x;
|
|
3505
|
+
const y = ctx.params.y;
|
|
3506
|
+
if (typeof x !== "number" || typeof y !== "number") return null;
|
|
3507
|
+
let tracker = ctx.sessionState.get(TRACKER_KEY);
|
|
3508
|
+
if (!tracker) {
|
|
3509
|
+
tracker = { samples: [], lastMouseDownAt: 0, isDragging: false };
|
|
3510
|
+
ctx.sessionState.set(TRACKER_KEY, tracker);
|
|
3261
3511
|
}
|
|
3262
|
-
if (
|
|
3263
|
-
|
|
3512
|
+
if (type === "mousePressed") {
|
|
3513
|
+
const priorResult = analyzeTrajectory(tracker.samples);
|
|
3514
|
+
tracker.lastMouseDownAt = Date.now();
|
|
3515
|
+
tracker.isDragging = true;
|
|
3516
|
+
tracker.samples = [];
|
|
3517
|
+
return priorResult;
|
|
3264
3518
|
}
|
|
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;
|
|
3519
|
+
if (type === "mouseReleased") {
|
|
3520
|
+
tracker.isDragging = false;
|
|
3521
|
+
const result = analyzeTrajectory(tracker.samples);
|
|
3522
|
+
tracker.samples = [];
|
|
3523
|
+
return result;
|
|
3340
3524
|
}
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3525
|
+
if (type === "mouseMoved") {
|
|
3526
|
+
const now = Date.now();
|
|
3527
|
+
const prevSample = tracker.samples[tracker.samples.length - 1];
|
|
3528
|
+
const distanceTraveled = prevSample ? prevSample.distanceTraveled + distance(prevSample.x, prevSample.y, x, y) : 0;
|
|
3529
|
+
const sample = { x, y, timestamp: now, distanceTraveled };
|
|
3530
|
+
tracker.samples.push(sample);
|
|
3531
|
+
if (tracker.samples.length > MAX_SAMPLES) {
|
|
3532
|
+
tracker.samples.shift();
|
|
3533
|
+
}
|
|
3534
|
+
return null;
|
|
3345
3535
|
}
|
|
3346
|
-
if (
|
|
3347
|
-
|
|
3536
|
+
if (type === "mouseReleased" && tracker.samples.length < MIN_SAMPLES_FOR_ANALYSIS) {
|
|
3537
|
+
tracker.samples = [];
|
|
3538
|
+
return null;
|
|
3348
3539
|
}
|
|
3540
|
+
return null;
|
|
3349
3541
|
}
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3542
|
+
};
|
|
3543
|
+
function analyzeTrajectory(samples) {
|
|
3544
|
+
if (samples.length < MIN_SAMPLES_FOR_ANALYSIS) return null;
|
|
3545
|
+
const issues = [];
|
|
3546
|
+
const startX = samples[0].x;
|
|
3547
|
+
const startY = samples[0].y;
|
|
3548
|
+
const endX = samples[samples.length - 1].x;
|
|
3549
|
+
const endY = samples[samples.length - 1].y;
|
|
3550
|
+
const lineLength = distance(startX, startY, endX, endY);
|
|
3551
|
+
const collinearityResult = checkCollinearity(samples, startX, startY, endX, endY, lineLength);
|
|
3552
|
+
if (collinearityResult) {
|
|
3553
|
+
issues.push(collinearityResult);
|
|
3353
3554
|
}
|
|
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
|
-
});
|
|
3555
|
+
const velocityResult = checkConstantVelocity(samples);
|
|
3556
|
+
if (velocityResult) {
|
|
3557
|
+
issues.push(velocityResult);
|
|
3397
3558
|
}
|
|
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;
|
|
3559
|
+
const jitterResult = checkJitter(samples);
|
|
3560
|
+
if (jitterResult) {
|
|
3561
|
+
issues.push(jitterResult);
|
|
3409
3562
|
}
|
|
3410
|
-
|
|
3563
|
+
if (issues.length === 0) return null;
|
|
3564
|
+
const stopX = samples[samples.length - 1].x;
|
|
3565
|
+
const stopY = samples[samples.length - 1].y;
|
|
3566
|
+
return {
|
|
3567
|
+
ruleId: "mouse-trajectory",
|
|
3568
|
+
action: "pass",
|
|
3569
|
+
severity: "danger",
|
|
3570
|
+
reason: `Suspicious mouse trajectory: ${issues.join("; ")}`,
|
|
3571
|
+
suggestion: `This mouse movement appears automated (straight line A\u2192B, no natural variation).
|
|
3572
|
+
Use a humanized mouse API that generates:
|
|
3573
|
+
- Bezier curves instead of straight lines
|
|
3574
|
+
- Random acceleration/deceleration
|
|
3575
|
+
- 1-3px micro-jitter per sample
|
|
3411
3576
|
|
|
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 };
|
|
3577
|
+
Example: The 'faker' or 'ghost-cursor' libraries generate realistic mouse paths.
|
|
3578
|
+
Target was: (${Math.round(startX)}, ${Math.round(startY)}) \u2192 (${Math.round(stopX)}, ${Math.round(stopY)})`,
|
|
3579
|
+
errorCode: -32002,
|
|
3580
|
+
errorMessage: "[CDP Firewall] Automated mouse trajectory blocked \u2014 appears non-human"
|
|
3581
|
+
};
|
|
3437
3582
|
}
|
|
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));
|
|
3583
|
+
function distance(x1, y1, x2, y2) {
|
|
3584
|
+
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
|
3452
3585
|
}
|
|
3453
|
-
function
|
|
3454
|
-
if (
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
}
|
|
3586
|
+
function checkCollinearity(samples, x1, y1, x2, y2, lineLength) {
|
|
3587
|
+
if (lineLength < 5) return null;
|
|
3588
|
+
let maxDeviation = 0;
|
|
3589
|
+
const dx = x2 - x1;
|
|
3590
|
+
const dy = y2 - y1;
|
|
3591
|
+
for (let i = 1; i < samples.length - 1; i++) {
|
|
3592
|
+
const { x, y } = samples[i];
|
|
3593
|
+
const cross = Math.abs(dy * x - dx * y + x2 * y1 - y2 * x1);
|
|
3594
|
+
const dev = cross / lineLength;
|
|
3595
|
+
if (dev > maxDeviation) maxDeviation = dev;
|
|
3460
3596
|
}
|
|
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)`;
|
|
3597
|
+
if (maxDeviation < 1.5 && lineLength > 20) {
|
|
3598
|
+
return `Perfectly straight line (max deviation ${maxDeviation.toFixed(1)}px over ${lineLength.toFixed(0)}px)`;
|
|
3694
3599
|
}
|
|
3695
3600
|
if (maxDeviation < 0.5 && lineLength > 10) {
|
|
3696
3601
|
return `Near-perfect straight line (max deviation ${maxDeviation.toFixed(1)}px)`;
|
|
@@ -3802,7 +3707,7 @@ function analyzeKeyTiming(samples) {
|
|
|
3802
3707
|
if (allIdentical) {
|
|
3803
3708
|
return {
|
|
3804
3709
|
ruleId: "input-keystroke",
|
|
3805
|
-
action: "
|
|
3710
|
+
action: "pass",
|
|
3806
3711
|
severity: "danger",
|
|
3807
3712
|
reason: `All ${intervals.length} keystroke intervals are exactly ${intervals[0]}ms \u2014 impossible for human typing.`,
|
|
3808
3713
|
suggestion: `Use page.fill(selector, text) instead of page.type() with delay.
|
|
@@ -3814,7 +3719,7 @@ Or add random variation: page.type(selector, text, {delay: 50 + Math.random() *
|
|
|
3814
3719
|
if (cv < 0.08) {
|
|
3815
3720
|
return {
|
|
3816
3721
|
ruleId: "input-keystroke",
|
|
3817
|
-
action: "
|
|
3722
|
+
action: "pass",
|
|
3818
3723
|
severity: "warn",
|
|
3819
3724
|
reason: `Unnatural keystroke timing (CV=${cv.toFixed(3)}). Human typing has CV > 0.2 on average.`,
|
|
3820
3725
|
suggestion: `Add random variation to your typing delay: page.type(selector, text, {delay: 50 + Math.random() * 80}).`,
|
|
@@ -3910,7 +3815,7 @@ function makeDecision(p, context) {
|
|
|
3910
3815
|
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
3816
|
return {
|
|
3912
3817
|
ruleId: "automation-signals",
|
|
3913
|
-
action: "
|
|
3818
|
+
action: "pass",
|
|
3914
3819
|
severity: p.severity,
|
|
3915
3820
|
reason: `Automation marker detected: "${p.name}". Context: "${context}..."`,
|
|
3916
3821
|
suggestion,
|
|
@@ -3985,7 +3890,7 @@ var fingerprintingRule = {
|
|
|
3985
3890
|
if (p.pattern.test(userCode)) {
|
|
3986
3891
|
return {
|
|
3987
3892
|
ruleId: "fingerprinting",
|
|
3988
|
-
action: "
|
|
3893
|
+
action: "pass",
|
|
3989
3894
|
severity: p.severity,
|
|
3990
3895
|
reason: `Browser fingerprinting API accessed: "${p.name}". Anti-crawler systems use this to identify your browser.`,
|
|
3991
3896
|
suggestion: p.suggestion,
|
|
@@ -4002,7 +3907,7 @@ var fingerprintingRule = {
|
|
|
4002
3907
|
var EVENT_PATTERNS = [
|
|
4003
3908
|
// ── Direct method calls (all isTrusted=false) ─────────
|
|
4004
3909
|
{ 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: "
|
|
3910
|
+
{ 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
3911
|
{ pattern: /\.blur\s*\(\s*\)/, name: "el.blur()", severity: "danger", errorCode: -32071, suggestion: "el.blur() without user interaction. Avoid in automation scripts." },
|
|
4007
3912
|
{ 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
3913
|
{ 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 +3922,7 @@ var EVENT_PATTERNS = [
|
|
|
4017
3922
|
{ pattern: /\.showPicker\s*\(\s*\)/, name: "HTMLInputElement.showPicker()", severity: "info", errorCode: -32076, suggestion: "Date/color picker shown without click \u2014 detectable." },
|
|
4018
3923
|
{ pattern: /\.reportValidity\s*\(\s*\)/, name: "el.reportValidity()", severity: "info", errorCode: -32076, suggestion: "Validity reporting without form submission attempt." },
|
|
4019
3924
|
// ── dispatchEvent with synthetic events (isTrusted=false) ──
|
|
4020
|
-
{ pattern: /dispatchEvent\s*\(\s*new\s+(?:Event|CustomEvent)\s*\(/, name: "dispatchEvent(new Event/CustomEvent)", severity: "
|
|
3925
|
+
{ 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
3926
|
{ 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
3927
|
{ 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
3928
|
{ 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 +3951,9 @@ var eventSimulationRule = {
|
|
|
4046
3951
|
if (p.pattern.test(userCode)) {
|
|
4047
3952
|
return {
|
|
4048
3953
|
ruleId: "event-simulation",
|
|
4049
|
-
|
|
3954
|
+
// danger 级硬拦(el.click 等 100% 暴露的模式);
|
|
3955
|
+
// warning 级放行+提示(dispatchEvent 在部分合法场景如 DataTransfer paste 使用)
|
|
3956
|
+
action: p.severity === "danger" ? "block" : "pass",
|
|
4050
3957
|
severity: p.severity,
|
|
4051
3958
|
reason: `Event simulation detected: "${p.name}". Synthetic events have isTrusted=false and are 100% detectable.`,
|
|
4052
3959
|
suggestion: p.suggestion,
|
|
@@ -4105,7 +4012,7 @@ var emulationOverrideRule = {
|
|
|
4105
4012
|
if (ctx.method !== p.method) continue;
|
|
4106
4013
|
return {
|
|
4107
4014
|
ruleId: "emulation-override",
|
|
4108
|
-
action: "
|
|
4015
|
+
action: "pass",
|
|
4109
4016
|
severity: p.severity,
|
|
4110
4017
|
reason: `CDP emulation/override detected: "${p.name}". This creates detectable inconsistencies between the JS environment and real browser state.`,
|
|
4111
4018
|
suggestion: p.suggestion,
|
|
@@ -4135,7 +4042,7 @@ var networkAnomalyRule = {
|
|
|
4135
4042
|
if (!headerStr.includes("sec-ch-ua")) {
|
|
4136
4043
|
return {
|
|
4137
4044
|
ruleId: "network-anomaly",
|
|
4138
|
-
action: "
|
|
4045
|
+
action: "pass",
|
|
4139
4046
|
severity: "warn",
|
|
4140
4047
|
reason: "Network.setExtraHTTPHeaders called without Sec-CH-UA client hints \u2014 browser normally sends these.",
|
|
4141
4048
|
suggestion: "Modern browsers send Sec-CH-UA headers automatically. Adding custom headers without them creates detectable inconsistency.",
|
|
@@ -4149,7 +4056,7 @@ var networkAnomalyRule = {
|
|
|
4149
4056
|
case "Network.clearBrowserCache": {
|
|
4150
4057
|
return {
|
|
4151
4058
|
ruleId: "network-anomaly",
|
|
4152
|
-
action: "
|
|
4059
|
+
action: "pass",
|
|
4153
4060
|
severity: "warn",
|
|
4154
4061
|
reason: "Network.clearBrowserCache called \u2014 cache clearing mid-session is unnatural for real users.",
|
|
4155
4062
|
suggestion: "Avoid cache clearing during sessions. Start with a fresh profile if needed.",
|
|
@@ -4160,7 +4067,7 @@ var networkAnomalyRule = {
|
|
|
4160
4067
|
case "Network.clearBrowserCookies": {
|
|
4161
4068
|
return {
|
|
4162
4069
|
ruleId: "network-anomaly",
|
|
4163
|
-
action: "
|
|
4070
|
+
action: "pass",
|
|
4164
4071
|
severity: "warn",
|
|
4165
4072
|
reason: "Network.clearBrowserCookies called \u2014 wiping cookies mid-session is a scraper optimization.",
|
|
4166
4073
|
suggestion: "Cookies should only be cleared via normal browser flow (expiration, user action).",
|
|
@@ -4171,7 +4078,7 @@ var networkAnomalyRule = {
|
|
|
4171
4078
|
case "Network.setBlockedURLs": {
|
|
4172
4079
|
return {
|
|
4173
4080
|
ruleId: "network-anomaly",
|
|
4174
|
-
action: "
|
|
4081
|
+
action: "pass",
|
|
4175
4082
|
severity: "warn",
|
|
4176
4083
|
reason: "Network.setBlockedURLs blocks resource loading \u2014 this changes the page behavior and is detectable.",
|
|
4177
4084
|
suggestion: "Blocking images/fonts/etc creates measurable differences in performance and page rendering.",
|
|
@@ -4193,7 +4100,7 @@ var networkAnomalyRule = {
|
|
|
4193
4100
|
case "Fetch.enable": {
|
|
4194
4101
|
return {
|
|
4195
4102
|
ruleId: "network-anomaly",
|
|
4196
|
-
action: "
|
|
4103
|
+
action: "pass",
|
|
4197
4104
|
severity: "warn",
|
|
4198
4105
|
reason: "Fetch.enable intercepts all network requests \u2014 a man-in-the-middle approach used by scrapers.",
|
|
4199
4106
|
suggestion: "Do not use Fetch domain for network interception if avoiding detection.",
|
|
@@ -4205,179 +4112,496 @@ var networkAnomalyRule = {
|
|
|
4205
4112
|
return null;
|
|
4206
4113
|
}
|
|
4207
4114
|
}
|
|
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);
|
|
4115
|
+
};
|
|
4116
|
+
|
|
4117
|
+
// src/cdp-interceptor/rules/page-lifecycle.ts
|
|
4118
|
+
var STAT_KEY = "lifecycle-tracker";
|
|
4119
|
+
var pageLifecycleRule = {
|
|
4120
|
+
id: "page-lifecycle",
|
|
4121
|
+
name: "Page Lifecycle Anomaly Detection (10+ patterns)",
|
|
4122
|
+
priority: 80,
|
|
4123
|
+
canHandle(ctx) {
|
|
4124
|
+
const m = ctx.method;
|
|
4125
|
+
return m === "Page.navigate" || m === "Page.captureScreenshot" || m === "Page.printToPDF" || m === "Page.reload" || m === "Page.close" || m === "Runtime.evaluate" || m === "Runtime.callFunctionOn";
|
|
4126
|
+
},
|
|
4127
|
+
evaluate(ctx) {
|
|
4128
|
+
let state = ctx.sessionState.get(STAT_KEY);
|
|
4129
|
+
if (!state) {
|
|
4130
|
+
state = { navigations: [], screenshots: 0, printToPDF: false, evaluateCount: 0, lastNavTime: 0, lastEvalTime: 0 };
|
|
4131
|
+
ctx.sessionState.set(STAT_KEY, state);
|
|
4132
|
+
}
|
|
4133
|
+
const now = Date.now();
|
|
4134
|
+
switch (ctx.method) {
|
|
4135
|
+
case "Page.navigate": {
|
|
4136
|
+
state.navigations.push(now);
|
|
4137
|
+
if (state.navigations.length >= 2) {
|
|
4138
|
+
const prev = state.navigations[state.navigations.length - 2];
|
|
4139
|
+
const interval = now - prev;
|
|
4140
|
+
if (interval < 100) {
|
|
4141
|
+
return {
|
|
4142
|
+
ruleId: "page-lifecycle",
|
|
4143
|
+
action: "pass",
|
|
4144
|
+
severity: "warn",
|
|
4145
|
+
reason: `Multiple Page.navigate calls within ${interval}ms of each other \u2014 unnatural rapid navigation.`,
|
|
4146
|
+
suggestion: "Add proper waits between navigations: wait for page load before navigating again.",
|
|
4147
|
+
errorCode: -32100,
|
|
4148
|
+
errorMessage: "[CDP Firewall] Rapid navigation sequence detected"
|
|
4149
|
+
};
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
state.lastNavTime = now;
|
|
4153
|
+
return null;
|
|
4154
|
+
}
|
|
4155
|
+
case "Page.captureScreenshot": {
|
|
4156
|
+
state.screenshots++;
|
|
4157
|
+
if (state.lastNavTime > 0 && now - state.lastNavTime < 500) {
|
|
4158
|
+
return {
|
|
4159
|
+
ruleId: "page-lifecycle",
|
|
4160
|
+
action: "pass",
|
|
4161
|
+
severity: "danger",
|
|
4162
|
+
reason: "Page.captureScreenshot called within 500ms of navigation \u2014 content extraction pattern.",
|
|
4163
|
+
suggestion: "Wait for the page to fully render before taking screenshots: wait for load/networkidle.",
|
|
4164
|
+
errorCode: -32101,
|
|
4165
|
+
errorMessage: "[CDP Firewall] Pre-render screenshot blocked \u2014 content extraction pattern"
|
|
4166
|
+
};
|
|
4167
|
+
}
|
|
4168
|
+
if (state.screenshots > 3 && state.navigations.length < 2) {
|
|
4169
|
+
return {
|
|
4170
|
+
ruleId: "page-lifecycle",
|
|
4171
|
+
action: "pass",
|
|
4172
|
+
severity: "warn",
|
|
4173
|
+
reason: "Multiple screenshots on a single page without navigation \u2014 suspicious extraction behavior.",
|
|
4174
|
+
suggestion: "Consider if all screenshots are necessary.",
|
|
4175
|
+
errorCode: -32102,
|
|
4176
|
+
errorMessage: "[CDP Firewall] Excessive screenshot detection"
|
|
4177
|
+
};
|
|
4178
|
+
}
|
|
4179
|
+
return null;
|
|
4180
|
+
}
|
|
4181
|
+
case "Page.printToPDF": {
|
|
4182
|
+
return {
|
|
4183
|
+
ruleId: "page-lifecycle",
|
|
4184
|
+
action: "pass",
|
|
4185
|
+
severity: "danger",
|
|
4186
|
+
reason: "Page.printToPDF called \u2014 this is a telltale scraper pattern that gives away automation intent.",
|
|
4187
|
+
suggestion: "Avoid PDF generation. If you must, add significant delays and user-like interaction first.",
|
|
4188
|
+
errorCode: -32103,
|
|
4189
|
+
errorMessage: "[CDP Firewall] printToPDF blocked \u2014 scraper intent detected"
|
|
4190
|
+
};
|
|
4191
|
+
}
|
|
4192
|
+
case "Page.reload": {
|
|
4193
|
+
if (state.lastNavTime > 0 && now - state.lastNavTime < 1e3) {
|
|
4194
|
+
return {
|
|
4195
|
+
ruleId: "page-lifecycle",
|
|
4196
|
+
action: "pass",
|
|
4197
|
+
severity: "warn",
|
|
4198
|
+
reason: "Page.reload called immediately after navigate \u2014 unnatural fast-reload pattern.",
|
|
4199
|
+
suggestion: "Introduce delays between navigation and reload to simulate human behavior.",
|
|
4200
|
+
errorCode: -32104,
|
|
4201
|
+
errorMessage: "[CDP Firewall] Rapid reload detected"
|
|
4202
|
+
};
|
|
4203
|
+
}
|
|
4204
|
+
return null;
|
|
4205
|
+
}
|
|
4206
|
+
case "Page.close": {
|
|
4207
|
+
if (state.navigations.length < 2) {
|
|
4208
|
+
return {
|
|
4209
|
+
ruleId: "page-lifecycle",
|
|
4210
|
+
action: "pass",
|
|
4211
|
+
severity: "info",
|
|
4212
|
+
reason: "Page.close called after minimal interaction \u2014 zombie pages common in automation.",
|
|
4213
|
+
suggestion: "Ensure meaningful interaction before closing pages.",
|
|
4214
|
+
errorCode: -32105,
|
|
4215
|
+
errorMessage: "[CDP Firewall] Page close after minimal interaction"
|
|
4216
|
+
};
|
|
4217
|
+
}
|
|
4218
|
+
return null;
|
|
4219
|
+
}
|
|
4220
|
+
case "Runtime.evaluate":
|
|
4221
|
+
case "Runtime.callFunctionOn": {
|
|
4222
|
+
state.evaluateCount++;
|
|
4223
|
+
if (state.evaluateCount > 50 && state.navigations.length === 0) {
|
|
4224
|
+
return {
|
|
4225
|
+
ruleId: "page-lifecycle",
|
|
4226
|
+
action: "pass",
|
|
4227
|
+
severity: "info",
|
|
4228
|
+
reason: "50+ evaluate calls without any Page.navigate \u2014 data extraction without real browsing.",
|
|
4229
|
+
suggestion: "Navigate to a real page first. Evaluate on about:blank is suspicious.",
|
|
4230
|
+
errorCode: -32106,
|
|
4231
|
+
errorMessage: "[CDP Firewall] Excessive evaluate without navigation"
|
|
4232
|
+
};
|
|
4233
|
+
}
|
|
4234
|
+
return null;
|
|
4235
|
+
}
|
|
4236
|
+
default:
|
|
4237
|
+
return null;
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
};
|
|
4241
|
+
|
|
4242
|
+
// src/cdp-interceptor/rules-engine.ts
|
|
4243
|
+
var BUILTIN_RULES = [
|
|
4244
|
+
domMutationRule,
|
|
4245
|
+
automationSignalsRule,
|
|
4246
|
+
fingerprintingRule,
|
|
4247
|
+
eventSimulationRule,
|
|
4248
|
+
mouseTrajectoryRule,
|
|
4249
|
+
inputKeystrokeRule,
|
|
4250
|
+
emulationOverrideRule,
|
|
4251
|
+
networkAnomalyRule,
|
|
4252
|
+
pageLifecycleRule
|
|
4253
|
+
];
|
|
4254
|
+
function createRuleEngine(customRules) {
|
|
4255
|
+
const rules = [...BUILTIN_RULES, ...customRules ?? []].sort((a, b) => a.priority - b.priority);
|
|
4256
|
+
const sessionStates = /* @__PURE__ */ new Map();
|
|
4257
|
+
function getSessionState(sessionId) {
|
|
4258
|
+
let state = sessionStates.get(sessionId);
|
|
4259
|
+
if (!state) {
|
|
4260
|
+
state = /* @__PURE__ */ new Map();
|
|
4261
|
+
sessionStates.set(sessionId, state);
|
|
4262
|
+
}
|
|
4263
|
+
return state;
|
|
4264
|
+
}
|
|
4265
|
+
return {
|
|
4266
|
+
start() {
|
|
4267
|
+
sessionStates.clear();
|
|
4268
|
+
},
|
|
4269
|
+
stop() {
|
|
4270
|
+
sessionStates.clear();
|
|
4271
|
+
},
|
|
4272
|
+
evaluate(ctx) {
|
|
4273
|
+
const fullCtx = {
|
|
4274
|
+
...ctx,
|
|
4275
|
+
sessionState: getSessionState(ctx.sessionId)
|
|
4276
|
+
};
|
|
4277
|
+
for (const rule of rules) {
|
|
4278
|
+
if (rule.canHandle && !rule.canHandle(fullCtx)) continue;
|
|
4279
|
+
const decision = rule.evaluate(fullCtx);
|
|
4280
|
+
if (!decision) continue;
|
|
4281
|
+
if (decision.action !== "pass") return decision;
|
|
4282
|
+
}
|
|
4283
|
+
return null;
|
|
4284
|
+
}
|
|
4285
|
+
};
|
|
4286
|
+
}
|
|
4287
|
+
|
|
4288
|
+
// src/cdp-driver/connection.ts
|
|
4289
|
+
var CDPConnection = class _CDPConnection extends EventEmitter4 {
|
|
4290
|
+
ws;
|
|
4291
|
+
nextId = 1;
|
|
4292
|
+
/** CDP Guard:全部出站命令过规则引擎(env XBROWSER_CDP_GUARD=off 关闭)。
|
|
4293
|
+
* 173 条规则 / 9 模块:合成事件 block、指纹暴露警告、自动化信号拦截。 */
|
|
4294
|
+
static __guard;
|
|
4295
|
+
get guard() {
|
|
4296
|
+
if (_CDPConnection.__guard === void 0) {
|
|
4297
|
+
if (process.env.XBROWSER_CDP_GUARD === "off") {
|
|
4298
|
+
_CDPConnection.__guard = null;
|
|
4299
|
+
} else {
|
|
4300
|
+
try {
|
|
4301
|
+
const g = createRuleEngine();
|
|
4302
|
+
g.start();
|
|
4303
|
+
_CDPConnection.__guard = g;
|
|
4304
|
+
} catch {
|
|
4305
|
+
_CDPConnection.__guard = null;
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
return _CDPConnection.__guard;
|
|
4310
|
+
}
|
|
4311
|
+
pending = /* @__PURE__ */ new Map();
|
|
4312
|
+
closed = false;
|
|
4313
|
+
closeReason = null;
|
|
4314
|
+
/** Default session ID for flat session protocol (Target.attachToTarget) */
|
|
4315
|
+
defaultSessionId;
|
|
4316
|
+
constructor(wsOrUrl, sessionId) {
|
|
4317
|
+
super();
|
|
4318
|
+
this.setMaxListeners(0);
|
|
4319
|
+
this.defaultSessionId = sessionId;
|
|
4320
|
+
if (typeof wsOrUrl === "string") {
|
|
4321
|
+
const wsOptions = /^wss:\/\/\d+\.\d+\.\d+\.\d+/.test(wsOrUrl) ? { rejectUnauthorized: false } : void 0;
|
|
4322
|
+
this.ws = new WebSocket(wsOrUrl, wsOptions);
|
|
4323
|
+
} else {
|
|
4324
|
+
this.ws = wsOrUrl;
|
|
4325
|
+
}
|
|
4326
|
+
this.bindWebSocket();
|
|
4327
|
+
this.startKeepalive();
|
|
4328
|
+
}
|
|
4329
|
+
/** Send periodic WS pings to prevent idle-timeout disconnects (e.g. CF's 100s).
|
|
4330
|
+
* Also detects dead connections: if a pong isn't received within 10s of a
|
|
4331
|
+
* ping, the connection is considered dead and forcibly closed. */
|
|
4332
|
+
keepaliveTimer = null;
|
|
4333
|
+
pongTimer = null;
|
|
4334
|
+
/** 最近一次收到任何 WS 消息的时间——有数据流动即证明连接存活 */
|
|
4335
|
+
lastIncomingAt = Date.now();
|
|
4336
|
+
startKeepalive() {
|
|
4337
|
+
this.ws.on("pong", () => {
|
|
4338
|
+
if (this.pongTimer) {
|
|
4339
|
+
clearTimeout(this.pongTimer);
|
|
4340
|
+
this.pongTimer = null;
|
|
4341
|
+
}
|
|
4342
|
+
});
|
|
4343
|
+
this.keepaliveTimer = setInterval(() => {
|
|
4344
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
4345
|
+
if (!this.pongTimer) {
|
|
4346
|
+
this.pongTimer = setTimeout(() => {
|
|
4347
|
+
const recentlyActive = Date.now() - this.lastIncomingAt < 3e4;
|
|
4348
|
+
if (recentlyActive) {
|
|
4349
|
+
this.pongTimer = null;
|
|
4350
|
+
return;
|
|
4351
|
+
}
|
|
4352
|
+
if (!this.closed) {
|
|
4353
|
+
this.closed = true;
|
|
4354
|
+
this.closeReason = "keepalive timeout (no pong and idle >30s)";
|
|
4355
|
+
try {
|
|
4356
|
+
this.ws.terminate();
|
|
4357
|
+
} catch {
|
|
4358
|
+
}
|
|
4359
|
+
for (const [, pending] of this.pending) {
|
|
4360
|
+
clearTimeout(pending.timeout);
|
|
4361
|
+
pending.reject(new Error("Connection dead: keepalive timeout"));
|
|
4362
|
+
}
|
|
4363
|
+
this.pending.clear();
|
|
4364
|
+
this.emit("disconnect");
|
|
4365
|
+
}
|
|
4366
|
+
}, 6e4);
|
|
4367
|
+
}
|
|
4368
|
+
this.ws.ping?.();
|
|
4369
|
+
} else if (this.closed) {
|
|
4370
|
+
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
|
|
4371
|
+
this.keepaliveTimer = null;
|
|
4372
|
+
}
|
|
4373
|
+
}, 3e4);
|
|
4374
|
+
}
|
|
4375
|
+
/** Wait for the connection to be fully open */
|
|
4376
|
+
async ready() {
|
|
4377
|
+
if (this.ws.readyState === WebSocket.OPEN) return;
|
|
4378
|
+
if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
|
4379
|
+
throw new Error(`WebSocket already closed: ${this.closeReason ?? "unknown"}`);
|
|
4380
|
+
}
|
|
4381
|
+
return new Promise((resolve, reject) => {
|
|
4382
|
+
const onOpen = () => {
|
|
4383
|
+
this.ws.off("error", onError);
|
|
4384
|
+
resolve();
|
|
4385
|
+
};
|
|
4386
|
+
const onError = (err) => {
|
|
4387
|
+
this.ws.off("open", onOpen);
|
|
4388
|
+
reject(err);
|
|
4389
|
+
};
|
|
4390
|
+
this.ws.once("open", onOpen);
|
|
4391
|
+
this.ws.once("error", onError);
|
|
4392
|
+
});
|
|
4393
|
+
}
|
|
4394
|
+
/** Is the underlying WebSocket alive? */
|
|
4395
|
+
get isOpen() {
|
|
4396
|
+
return !this.closed && this.ws.readyState === WebSocket.OPEN;
|
|
4397
|
+
}
|
|
4398
|
+
/**
|
|
4399
|
+
* Send a CDP command and await its response.
|
|
4400
|
+
*
|
|
4401
|
+
* @param method — CDP domain.method (e.g. "Page.navigate")
|
|
4402
|
+
* @param params — method parameters
|
|
4403
|
+
* @param sessionId — optional flat session ID for sub-targets
|
|
4404
|
+
* @param timeoutMs — response timeout (default: 30s;重 SPA 加载期间主线程忙,
|
|
4405
|
+
* evaluate 可排队 >30s——Runtime.evaluate 单独放宽到 90s)
|
|
4406
|
+
* @returns the `result` field from the CDP response
|
|
4407
|
+
*/
|
|
4408
|
+
async send(method, params, sessionId, timeoutMs) {
|
|
4409
|
+
const effectiveTimeout = timeoutMs ?? (method === "Runtime.evaluate" ? 9e4 : 3e4);
|
|
4410
|
+
if (this.guard) {
|
|
4411
|
+
const decision = this.guard.evaluate({
|
|
4412
|
+
method,
|
|
4413
|
+
params: params ?? {},
|
|
4414
|
+
sessionId: sessionId ?? this.defaultSessionId ?? "",
|
|
4415
|
+
direction: "client\u2192browser"
|
|
4416
|
+
});
|
|
4417
|
+
if (decision && decision.action === "block") {
|
|
4418
|
+
throw new Error(`[CDP-Guard] ${decision.reason}${decision.suggestion ? " | \u66FF\u4EE3: " + decision.suggestion : ""} [${decision.ruleId}]`);
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
if (this.closed) {
|
|
4422
|
+
throw new Error(`CDP connection closed: ${this.closeReason ?? "unknown"}`);
|
|
4423
|
+
}
|
|
4424
|
+
if (!this.isOpen) {
|
|
4425
|
+
throw new Error(`CDP connection not open (state: ${this.ws.readyState})`);
|
|
4426
|
+
}
|
|
4427
|
+
const id = this.nextId++;
|
|
4428
|
+
const sid = sessionId ?? this.defaultSessionId;
|
|
4429
|
+
const message = { id, method };
|
|
4430
|
+
if (params !== void 0) message.params = params;
|
|
4431
|
+
if (sid !== void 0) message.sessionId = sid;
|
|
4432
|
+
return new Promise((resolve, reject) => {
|
|
4433
|
+
const timeout = setTimeout(() => {
|
|
4434
|
+
this.pending.delete(id);
|
|
4435
|
+
reject(new Error(`CDP timeout: ${method} (${effectiveTimeout}ms)`));
|
|
4436
|
+
}, effectiveTimeout);
|
|
4437
|
+
this.pending.set(id, {
|
|
4438
|
+
resolve: (v) => {
|
|
4439
|
+
clearTimeout(timeout);
|
|
4440
|
+
this.pending.delete(id);
|
|
4441
|
+
resolve(v);
|
|
4442
|
+
},
|
|
4443
|
+
reject: (err) => {
|
|
4444
|
+
clearTimeout(timeout);
|
|
4445
|
+
this.pending.delete(id);
|
|
4446
|
+
reject(err);
|
|
4447
|
+
},
|
|
4448
|
+
method,
|
|
4449
|
+
timeout
|
|
4450
|
+
});
|
|
4451
|
+
const data = JSON.stringify(message);
|
|
4452
|
+
try {
|
|
4453
|
+
this.ws.send(data);
|
|
4454
|
+
} catch (err) {
|
|
4455
|
+
clearTimeout(timeout);
|
|
4456
|
+
this.pending.delete(id);
|
|
4457
|
+
reject(new Error(`CDP send failed: ${method} \u2014 ${err instanceof Error ? err.message : String(err)}`));
|
|
4458
|
+
}
|
|
4459
|
+
});
|
|
4460
|
+
}
|
|
4461
|
+
/**
|
|
4462
|
+
* Subscribe to a CDP event.
|
|
4463
|
+
*
|
|
4464
|
+
* @param event — full event name (e.g. "Page.frameNavigated")
|
|
4465
|
+
* @param handler — called with the event params
|
|
4466
|
+
* @param sessionId — optional session filter
|
|
4467
|
+
*/
|
|
4468
|
+
on(event, handler) {
|
|
4469
|
+
return super.on(event, handler);
|
|
4470
|
+
}
|
|
4471
|
+
once(event, handler) {
|
|
4472
|
+
return super.once(event, handler);
|
|
4473
|
+
}
|
|
4474
|
+
/** Remove an event listener */
|
|
4475
|
+
off(event, handler) {
|
|
4476
|
+
super.off(event, handler);
|
|
4477
|
+
return this;
|
|
4478
|
+
}
|
|
4479
|
+
/**
|
|
4480
|
+
* Subscribe to a CDP event for a specific session.
|
|
4481
|
+
* Returns an unsubscribe function.
|
|
4482
|
+
*/
|
|
4483
|
+
subscribe(event, sessionId, handler) {
|
|
4484
|
+
const wrapper = (params, sid) => {
|
|
4485
|
+
if (sid === sessionId || !sessionId && !sid) handler(params);
|
|
4486
|
+
};
|
|
4487
|
+
this.on(event, wrapper);
|
|
4488
|
+
return () => this.off(event, wrapper);
|
|
4489
|
+
}
|
|
4490
|
+
/** Close the WebSocket */
|
|
4491
|
+
async close() {
|
|
4492
|
+
if (this.closed) return;
|
|
4493
|
+
this.closed = true;
|
|
4494
|
+
this.closeReason = "closed by caller";
|
|
4495
|
+
if (this.keepaliveTimer) {
|
|
4496
|
+
clearInterval(this.keepaliveTimer);
|
|
4497
|
+
this.keepaliveTimer = null;
|
|
4225
4498
|
}
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4499
|
+
if (this.pongTimer) {
|
|
4500
|
+
clearTimeout(this.pongTimer);
|
|
4501
|
+
this.pongTimer = null;
|
|
4502
|
+
}
|
|
4503
|
+
for (const [id, pending] of this.pending) {
|
|
4504
|
+
clearTimeout(pending.timeout);
|
|
4505
|
+
pending.reject(new Error(`Connection closed: ${pending.method}`));
|
|
4506
|
+
this.pending.delete(id);
|
|
4507
|
+
}
|
|
4508
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
4509
|
+
this.ws.close(1e3, "normal closure");
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
/** Set the default session ID for flat protocol */
|
|
4513
|
+
setDefaultSessionId(sid) {
|
|
4514
|
+
this.defaultSessionId = sid;
|
|
4515
|
+
}
|
|
4516
|
+
// ── Private ─────────────────────────────────────────────────
|
|
4517
|
+
bindWebSocket() {
|
|
4518
|
+
this.ws.on("message", (raw) => {
|
|
4519
|
+
this.lastIncomingAt = Date.now();
|
|
4520
|
+
let msg;
|
|
4521
|
+
try {
|
|
4522
|
+
msg = JSON.parse(raw.toString());
|
|
4523
|
+
} catch {
|
|
4524
|
+
return;
|
|
4247
4525
|
}
|
|
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
|
-
};
|
|
4526
|
+
if (msg.id !== void 0) {
|
|
4527
|
+
const pending = this.pending.get(msg.id);
|
|
4528
|
+
if (!pending) return;
|
|
4529
|
+
if (msg.error) {
|
|
4530
|
+
pending.reject(new CDPProtocolError(msg.error.code, msg.error.message, pending.method));
|
|
4531
|
+
} else {
|
|
4532
|
+
pending.resolve(msg.result ?? {});
|
|
4271
4533
|
}
|
|
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
|
-
};
|
|
4534
|
+
return;
|
|
4284
4535
|
}
|
|
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;
|
|
4536
|
+
if (msg.method) {
|
|
4537
|
+
this.emit(msg.method, msg.params ?? {}, msg.sessionId);
|
|
4538
|
+
this.emit("*", msg.method, msg.params ?? {}, msg.sessionId);
|
|
4298
4539
|
}
|
|
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;
|
|
4540
|
+
});
|
|
4541
|
+
this.ws.on("close", (code, reason) => {
|
|
4542
|
+
if (this.closed) return;
|
|
4543
|
+
this.closed = true;
|
|
4544
|
+
this.closeReason = `WebSocket closed: ${code} ${reason?.toString() ?? ""}`.trim();
|
|
4545
|
+
if (this.keepaliveTimer) {
|
|
4546
|
+
clearInterval(this.keepaliveTimer);
|
|
4547
|
+
this.keepaliveTimer = null;
|
|
4312
4548
|
}
|
|
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;
|
|
4549
|
+
for (const [id, pending] of this.pending) {
|
|
4550
|
+
clearTimeout(pending.timeout);
|
|
4551
|
+
pending.reject(new Error(`Connection closed: ${pending.method}`));
|
|
4552
|
+
this.pending.delete(id);
|
|
4328
4553
|
}
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4554
|
+
this.emit("disconnect");
|
|
4555
|
+
});
|
|
4556
|
+
this.ws.on("error", (err) => {
|
|
4557
|
+
if (this.closed) return;
|
|
4558
|
+
this.emit("ws-error", err);
|
|
4559
|
+
});
|
|
4560
|
+
}
|
|
4561
|
+
};
|
|
4562
|
+
var CDPProtocolError = class extends Error {
|
|
4563
|
+
code;
|
|
4564
|
+
method;
|
|
4565
|
+
data;
|
|
4566
|
+
constructor(code, message, method, data) {
|
|
4567
|
+
super(`CDP error [${code}] in ${method}: ${message}`);
|
|
4568
|
+
this.name = "CDPProtocolError";
|
|
4569
|
+
this.code = code;
|
|
4570
|
+
this.method = method;
|
|
4571
|
+
this.data = data;
|
|
4332
4572
|
}
|
|
4333
4573
|
};
|
|
4334
4574
|
|
|
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;
|
|
4575
|
+
// src/cdp-driver/index.ts
|
|
4576
|
+
async function launch(options = {}) {
|
|
4577
|
+
let wsEndpoint;
|
|
4578
|
+
let childProcess;
|
|
4579
|
+
let tmpDir;
|
|
4580
|
+
if (options.cdpEndpoint) {
|
|
4581
|
+
wsEndpoint = await connectToCDP(options.cdpEndpoint);
|
|
4582
|
+
} else {
|
|
4583
|
+
const result = await launchChrome({
|
|
4584
|
+
executablePath: options.executablePath,
|
|
4585
|
+
headless: options.headless,
|
|
4586
|
+
args: options.args,
|
|
4587
|
+
userDataDir: options.userDataDir,
|
|
4588
|
+
timeout: options.timeout,
|
|
4589
|
+
env: options.env
|
|
4590
|
+
});
|
|
4591
|
+
wsEndpoint = result.wsEndpoint;
|
|
4592
|
+
childProcess = result.process;
|
|
4593
|
+
tmpDir = result.tmpDir;
|
|
4357
4594
|
}
|
|
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
|
-
};
|
|
4595
|
+
const conn = new CDPConnection(wsEndpoint);
|
|
4596
|
+
await conn.ready();
|
|
4597
|
+
const httpEndpoint = options.cdpEndpoint && !options.cdpEndpoint.startsWith("ws") ? options.cdpEndpoint : void 0;
|
|
4598
|
+
const browser = new XBBrowserImpl(conn, childProcess, tmpDir, httpEndpoint);
|
|
4599
|
+
return { browser, wsEndpoint };
|
|
4379
4600
|
}
|
|
4380
4601
|
|
|
4602
|
+
// src/cdp-interceptor/proxy.ts
|
|
4603
|
+
import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
|
|
4604
|
+
|
|
4381
4605
|
// src/cdp-interceptor/logger.ts
|
|
4382
4606
|
function createLogger(config) {
|
|
4383
4607
|
const buffer = [];
|