diff --git a/scripts/mobile-shot.mjs b/scripts/mobile-shot.mjs new file mode 100644 index 00000000..fcd82b0f --- /dev/null +++ b/scripts/mobile-shot.mjs @@ -0,0 +1,184 @@ +/* + * CyberStrikeAI — mobile shell screenshots. + * + * Captures the injected mobile chrome (appbar / tab bar / drawer / bottom sheet) + * at a phone viewport without needing a login: the chrome is injected by + * mobile-ui.js boot() purely from the viewport width, so hiding the login + * overlay is enough to make syncAuthChrome re-show the tab bar. Data-driven page + * bodies stay empty — this is for judging the *shell*, not the content. + * + * node scripts/mobile-shot.mjs --state=plain --out=/tmp/shot.png + * node scripts/mobile-shot.mjs --state=drawer --base=http://127.0.0.1:8088 + * states: login | plain | drawer | sheet | chat + */ +import fs from 'node:fs'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const argv = Object.fromEntries(process.argv.slice(2).map(a => { + const i = a.indexOf('='); + return i === -1 ? [a.replace(/^--/, ''), '1'] : [a.slice(2, i), a.slice(i + 1)]; +})); +const W = Number(argv.width || 390), H = Number(argv.height || 844); +const PORT = argv.port || '9713'; +const BASE = (argv.base || 'http://127.0.0.1:8088').replace(/\/$/, ''); +const STATE = argv.state || 'plain'; +const OUT = argv.out || `/tmp/cs-shot/${STATE}-${W}x${H}.png`; + +fs.mkdirSync(dirnameOf(OUT), { recursive: true }); +function dirnameOf(p) { const i = p.lastIndexOf('/'); return p.slice(0, i) || '.'; } + +const CH = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const chrome = await import('node:child_process').then(m => + m.spawn(CH, ['--headless=new', `--remote-debugging-port=${PORT}`, + `--user-data-dir=/tmp/cs-shot-${PORT}`, '--no-sandbox', 'about:blank'], { stdio: 'ignore' })); +process.on('exit', () => { try { chrome.kill('SIGKILL'); } catch { /* gone */ } }); + +let ws; +for (let i = 0; i < 80; i++) { + try { + const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); + ws = new WebSocket((await r.json()).webSocketDebuggerUrl); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; setTimeout(rej, 4000); }); + break; + } catch { await sleep(300); } +} +if (!ws) { console.error('chrome devtools unreachable'); process.exit(1); } + +let idc = 0; const pending = new Map(); +ws.onmessage = e => { + const m = JSON.parse(e.data); + if (m.id && pending.has(m.id)) { + const { res, rej } = pending.get(m.id); pending.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } +}; +const send = (method, params = {}, sessionId) => new Promise((res, rej) => { + const id = ++idc; pending.set(id, { res, rej }); + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); +}); + +const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); +const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); +for (const m of ['Page.enable', 'Runtime.enable']) await send(m, {}, sessionId); +await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 3, mobile: true }, sessionId); +await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }, sessionId); +await send('Network.setUserAgentOverride', { + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' +}, sessionId); +const ev = async expr => { + const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }, sessionId); + if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; + return r.result?.value; +}; + +/* 主题要先落进本站点的 localStorage,about:blank 上写是写给另一个 origin。 + 换主题用 Page.navigate 重新进,不要在 eval 里 location.reload()——awaitPromise + 会等一个永远不回来的执行上下文。 */ +await send('Page.navigate', { url: BASE + '/' }, sessionId); +await sleep(1500); +const theme = argv.theme || (argv.dark === '1' ? 'dark' : ''); +if (theme) { + await ev(`localStorage.setItem('cyberstrike-theme', ${JSON.stringify(theme)})`); + await send('Page.navigate', { url: BASE + '/' }, sessionId); +} +await sleep(3000); + +if (STATE !== 'login') { + /* 藏掉登录遮罩会触发 mobile-ui.js 的 MutationObserver,把底部栏显示回来 */ + await ev(`document.getElementById('login-overlay').style.display='none'`); + await sleep(600); + if (STATE === 'drawer') await ev(`window.CSAMobile && CSAMobile.openDrawer()`); + if (STATE === 'sheet') await ev(`window.CSAMobile && CSAMobile.openSheet()`); + if (STATE === 'chat') { await ev(`window.switchPage && switchPage('chat')`); await sleep(1500); } + await sleep(1200); +} + +const state = await ev(`(()=>({ + booted: !!window.CSAMobile, + tabbar: !!document.querySelector('#m-tabbar') && getComputedStyle(document.querySelector('#m-tabbar')).display, + sheet: !!document.querySelector('#m-sheet'), + drawerOpen: !!document.querySelector('#main-sidebar.m-open'), + sheetOpen: !!document.querySelector('#m-sheet.m-show'), + lang: document.documentElement.getAttribute('lang') +}))()`); +console.log(JSON.stringify(state)); + +/* 带凭据时真的登进去:未登录时对话/列表页的容器根本不渲染,看不到真东西。 + 凭据同样只从参数或环境变量来,绝不写进文件。 */ +const USER = argv.user || process.env.CSAI_AUDIT_USER || ''; +const PASS = argv.pass || process.env.CSAI_AUDIT_PASS || ''; +const COOKIE = argv.cookie || process.env.CSAI_AUDIT_COOKIE || ''; +if (COOKIE) await send('Network.setCookie', { name: 'auth_token', value: COOKIE, domain: new URL(BASE).hostname, path: '/' }, sessionId); +if (USER && PASS) { + await ev(`(()=>{const u=document.getElementById('login-username'),p=document.getElementById('login-password');if(u&&p){u.value=${JSON.stringify(USER)};p.value=${JSON.stringify(PASS)};document.getElementById('login-form').requestSubmit();}})()`); + await sleep(7000); + console.log('logged in:', JSON.stringify(await ev(`(()=>{const o=document.getElementById('login-overlay');return o?getComputedStyle(o).display:'none'})()`))); +} +if (argv.route) { await ev(`location.hash=${JSON.stringify(argv.route)}`); await sleep(3000); } + +/* --eval='...' 用来在同一个已登录/已注入的页面上量一把真实计算样式 */ +if (argv.eval) { + const probe = await ev(`(function(){${argv.eval}})()`); + console.log(JSON.stringify(probe, null, 2)); +} + +/* --why='.notification-btn' --prop=border-radius + 规则写了却不生效时,用 CDP 的匹配样式表分清:没命中,还是被更高优先级压住。 */ +if (argv.why) { + await send('DOM.enable', {}, sessionId); + await send('CSS.enable', {}, sessionId); + const { root } = await send('DOM.getDocument', { depth: -1 }, sessionId); + const { nodeId } = await send('DOM.querySelector', { nodeId: root.nodeId, selector: argv.why }, sessionId); + if (!nodeId) { console.log('selector 未命中任何节点:', argv.why); } + else { + const matched = await send('CSS.getMatchedStylesForNode', { nodeId }, sessionId); + const want = (argv.prop || 'border-radius,border,background').split(','); + const rows = []; + for (const e of matched.matchedCSSRules || []) { + const m = e.rule; + const decl = (m.style.cssProperties || []).filter(p => want.includes(p.name) && !p.disabled && p.value); + if (!decl.length) continue; + const sel = (m.selectorList && m.selectorList.text) || '?'; + rows.push({ + origin: m.origin, + sheet: (m.style.styleSheetId || '').split('@').pop(), + selector: sel.length > 78 ? sel.slice(0, 78) + '…' : sel, + decls: decl.map(d => `${d.name}:${d.value}${d.important ? ' !important' : ''}`), + }); + } + console.log(`== ${argv.why} 上命中且声明了 [${want.join(', ')}] 的规则,按级联顺序(后=更优先)==`); + rows.forEach((r, i) => console.log(`${String(i).padStart(2)} ${r.origin.padEnd(10)} ${r.decls.join(' | ')}\n ${r.selector}`)); + const cs = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(argv.why)});const c=getComputedStyle(e);return JSON.stringify(${JSON.stringify(want)}.reduce((o,k)=>(o[k]=c[k],o),{}),null,1)})()`); + console.log('最终计算值:', cs); + } +} + +/* --tap='SEL' 用真手指(只发 touch)点一个选择器,配合 --shots=3 连拍, + 用来看"点下去之后屏幕上闪出来的那块是什么"。 */ +async function fingerTap(x, y) { + const t = { x: Math.round(x), y: Math.round(y) }; + await send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [t] }, sessionId); + await sleep(40); + await send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId); +} +if (argv.tap) { + const box = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(argv.tap)});if(!e)return null;const r=e.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2}})()`); + if (!box) console.log('tap 目标不存在:', argv.tap); + else { + await fingerTap(box.x, box.y); + const shots = Number(argv.shots || 3); + for (let i = 0; i < shots; i++) { + const png = await send('Page.captureScreenshot', { format: 'png' }, sessionId); + fs.writeFileSync(OUT.replace(/\.png$/, '') + `-t${i}.png`, Buffer.from(png.data, 'base64')); + const who = await ev(`(()=>{const e=document.elementFromPoint(innerWidth/2,innerHeight/2);if(!e)return null;var s=e.tagName.toLowerCase()+(e.id?"#"+e.id:"")+(typeof e.className==="string"&&e.className?"."+e.className.trim().split(/\s+/)[0]:"");var r=e.getBoundingClientRect();var c=getComputedStyle(e);return JSON.stringify({el:s,z:c.zIndex,pos:c.position,box:[Math.round(r.width),Math.round(r.height)],bg:c.backgroundColor})})()`); + console.log(`t${i} 视口中央:`, who); + await sleep(Number(argv.shotDelay || 300)); + } + process.exit(0); + } +} + +const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); +fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64')); +console.log('shot -> ' + OUT); +process.exit(0); diff --git a/scripts/mobile-tap-audit.mjs b/scripts/mobile-tap-audit.mjs new file mode 100644 index 00000000..56575916 --- /dev/null +++ b/scripts/mobile-tap-audit.mjs @@ -0,0 +1,460 @@ +/* + * CyberStrikeAI — mobile tap audit. + * + * Proves the claim "every button on a phone can actually be tapped and shows up + * correctly". It is deliberately narrower than scripts/mobile-audit.mjs (layout + * defects): here every interactive element in the *active* page must pass four + * geometric/reachability checks, and the injected mobile chrome must pass a + * scripted finger tap. + * + * node scripts/mobile-tap-audit.mjs --user=admin --pass=... --width=390 --height=844 + * CSAI_AUDIT_USER=admin CSAI_AUDIT_PASS=... node scripts/mobile-tap-audit.mjs --all + * node scripts/mobile-tap-audit.mjs --cookie= --all + * + * Flags: --base=https://127.0.0.1:8088 --pages=a,b,c --allow-writes=0|1 --ua=iphone|android + * --chrome=0 (skip the finger-tap suite) --json= + * + * Nothing destructive is tapped: elements whose label matches a write verb are + * measured but never clicked, and 新对话-style writes need --allow-writes=1. + * Credentials are never defaulted — a baked-in password leaks the moment this + * file is committed or served. + */ +import fs from 'node:fs'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const argv = Object.fromEntries(process.argv.slice(2).map(a => { + const i = a.indexOf('='); + return i === -1 ? [a.replace(/^--/, ''), '1'] : [a.slice(2, i), a.slice(i + 1)]; +})); + +const W = Number(argv.width || 390), H = Number(argv.height || 844); +const PORT = argv.port || '9712'; +const BASE = (argv.base || 'https://127.0.0.1:8088').replace(/\/$/, ''); +const USER = argv.user || process.env.CSAI_AUDIT_USER || ''; +const PASS = argv.pass || process.env.CSAI_AUDIT_PASS || ''; +/* 不想交出口令时的替代路径:在浏览器里登录 127.0.0.1:8088,从 DevTools 复制 + auth_token 的值,用 --cookie=... 或 CSAI_AUDIT_COOKIE=... 传进来。会话本身 12 小时 + 过期,比长期有效的主机口令暴露面小。两者都没有则拒绝运行。 */ +const COOKIE = argv.cookie || process.env.CSAI_AUDIT_COOKIE || ''; +const ALLOW_WRITES = argv['allow-writes'] === '1'; +const MIN_TAP = Number(argv['min-tap'] || 44); +const CHROME = argv.chrome !== '0'; +const OUT = argv.out || `/tmp/cs-tap/${W}x${H}`; +const JSON_OUT = argv.json || `${OUT}/tap-audit.json`; +const BASELINE = argv.baseline || 'scripts/mobile-tap-baseline.json'; + +if (!COOKIE && (!USER || !PASS)) { + console.error('Refusing to run without credentials: pass --user=... --pass=... (or CSAI_AUDIT_USER/CSAI_AUDIT_PASS), or an existing session via --cookie=... (or CSAI_AUDIT_COOKIE).'); + process.exit(2); +} + +const SPA_PAGES = (argv.pages || (argv.all ? '' : 'dashboard,chat,hitl,tasks')) + .split(',').filter(Boolean); + +const PAGES = argv.all + ? ['dashboard', 'chat', 'projects', 'vulnerabilities', 'tasks', 'workflows', 'asset-overview', + 'asset-library', 'info-collect', 'hitl', 'tool-guard', 'webshell', 'c2-listeners', 'c2-sessions', + 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles', 'chat-files', 'mcp-monitor', 'mcp-management', + 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', + 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'audit-log', 'monitor'] + : SPA_PAGES; + +fs.mkdirSync(OUT, { recursive: true }); + +/* Write verbs: an element carrying one of these in its label is measured, never + tapped. Without this the audit would delete real rows in whatever instance it + points at. */ +const WRITE_RE = /(删除|清空|清理|退出|注销|重启|停止|终止|中止|执行|运行|发送|提交|保存|新建|新增|创建|导入|上传|应用|重置|启用|禁用|安装|卸载|开始|立即|一键|批量|覆盖|恢复|回滚|delete|remove|clear|logout|submit|save|create|import|upload|apply|reset|install|uninstall|run|execute|send|restart|stop|kill|rollback|restore)/i; + +const CH = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const chrome = await import('node:child_process').then(m => + m.spawn(CH, ['--headless=new', `--remote-debugging-port=${PORT}`, + `--user-data-dir=/tmp/cs-tap-${PORT}`, '--ignore-certificate-errors', + '--no-sandbox', '--allow-running-insecure-content', 'about:blank'], { stdio: 'ignore' })); +/* 任何提前退出(连不上、登录失败)都不许把 headless Chrome 留在机器上 */ +process.on('exit', () => { try { chrome.kill('SIGKILL'); } catch { /* 已退出 */ } }); + +let ws; +for (let i = 0; i < 80; i++) { + try { + const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); + ws = new WebSocket((await r.json()).webSocketDebuggerUrl); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; setTimeout(rej, 4000); }); + break; + } catch { await sleep(300); } +} +if (!ws) { console.error('chrome devtools unreachable'); process.exit(1); } + +let idc = 0; const pending = new Map(); const events = []; +ws.onmessage = e => { + const m = JSON.parse(e.data); + if (m.id && pending.has(m.id)) { + const { res, rej } = pending.get(m.id); pending.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } else if (m.method) { events.push(m.method); } +}; +const send = (method, params = {}, sessionId) => new Promise((res, rej) => { + const id = ++idc; pending.set(id, { res, rej }); + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); +}); + +const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); +const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); +for (const m of ['Page.enable', 'Runtime.enable', 'Security.enable', 'Log.enable']) await send(m, {}, sessionId); +await send('Security.setIgnoreCertificateErrors', { ignore: true }, sessionId); +await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 3, mobile: true }, sessionId); +await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }, sessionId); +/* --ua=android 用安卓 Chrome 的 UA 复跑同一套门禁(壳层按视口宽度判定,与 UA 无关; + 这里换 UA 是为了让"同一份 UI 在安卓浏览器下同样合格"有可复现的证据)。 */ +const UA_STRINGS = { + iphone: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + android: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36' +}; +const UA = UA_STRINGS[(argv.ua || 'iphone').toLowerCase()]; +if (!UA) { console.error(`unknown --ua=${argv.ua} (iphone | android)`); process.exit(2); } +await send('Network.setUserAgentOverride', { userAgent: UA }, sessionId); + +const ev = async expr => { + const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }, sessionId); + if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; + return r.result?.value; +}; + +/* 真手指只有 touch:Chromium 在移动模拟下会自己从 touchEnd 合成一次 click。 + 再补一对 mouse 事件就是第二次 click —— 抽屉/面板会被「开了又关」, + 于是 menu-open 这类断言假失败(实测踩过)。 */ +async function fingerTap(x, y) { + const t = { x: Math.round(x), y: Math.round(y) }; + await send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [t] }, sessionId); + await sleep(40); + await send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId); + await sleep(260); +} + +const DETECT = `(function(){ + var vw=innerWidth, vh=innerHeight, MIN=${MIN_TAP}; + var SEL='button,a[href],input:not([type=hidden]),select,textarea,[onclick],[role=button],[role=tab],[role=menuitem],[role=switch],[role=checkbox],[role=link],.nav-item-content,.nav-submenu-item,.settings-custom-select-trigger,.tab,.switch'; + function nm(el){ + var s=el.tagName.toLowerCase(); + if(el.id) s+='#'+el.id; + else if(typeof el.className==='string'&&el.className.trim()){ + var c=el.className.trim().split(/\\s+/).filter(function(x){return !/^is-|^active$|^has-/.test(x)}); + s+='.'+(c[0]||''); if(c[1]&&/layout|sidebar|content|panel|main|nav|list|detail|grid|row|header|body|wrap|table|bar|tab/i.test(c[1])) s+='.'+c[1]; + } + return s; + } + function path(el){ var p=[],n=el; for(var i=0;i<4&&n&&n.tagName;i++){p.unshift(nm(n));n=n.parentElement} return p.join('>') } + function label(el){ + var t=(el.innerText||'').trim(); + if(!t) t=(el.value||'').trim(); + if(!t) t=el.getAttribute('aria-label')||el.getAttribute('title')||el.getAttribute('placeholder')||''; + if(!t&&el.getAttribute('data-i18n')) t=el.getAttribute('data-i18n'); + if(!t){ var i=el.querySelector('i,svg,img'); t=(el.className||'')+'[icon]'; } + return String(t).replace(/\\s+/g,' ').trim().slice(0,42); + } + function shown(el){ + var n=el; + while(n&&n!==document.documentElement){ + var c=getComputedStyle(n); + if(c.display==='none'||c.visibility==='hidden'||parseFloat(c.opacity)===0) return false; + n=n.parentElement; + } + return true; + } + function inActivePage(el){ + var p=el.closest('.page'); + return !p||p.classList.contains('active'); + } + function scrollAncestor(el,axis){ + var a=el.parentElement; + while(a){ var c=getComputedStyle(a); + if(axis==='x'&&/auto|scroll/.test(c.overflowX)) return a; + if(axis==='y'&&/auto|scroll/.test(c.overflowY)) return a; + if(a===document.body) break; a=a.parentElement; } + return null; + } + function clipAncestor(el){ + var a=el.parentElement; + while(a){ var c=getComputedStyle(a); + if(c.overflowX==='hidden'||c.overflowY==='hidden'||c.overflow==='hidden') return a; + if(a===document.body) break; a=a.parentElement; } + return null; + } + var fixedTally=function(){ + var out=[]; + [...document.querySelectorAll('*')].forEach(function(el){ + var c=getComputedStyle(el); + if(c.position!=='fixed'&&c.position!=='sticky') return; + if(!shown(el)) return; + var r=el.getBoundingClientRect(); + if(r.width<1||r.height<1) return; + if(c.pointerEvents==='none') return; + if(parseFloat(c.zIndex||0)<0&&c.zIndex!=='auto') {} + out.push({el:el,r:r,n:nm(el),z:c.zIndex}); + }); + return out; + }(); + function overFixed(r){ + for(var i=0;i=f.r.top&&r.bottom<=f.r.bottom&&r.left>=f.r.left&&r.right<=f.r.right) return f.n; + } + return null; + } + + var bad=[], scanned=0, chromeSeen={}; + var list=[...document.querySelectorAll(SEL)]; + list.forEach(function(el){ + if(!shown(el)||!inActivePage(el)) return; + /* 折叠
里的控件本来就要展开才存在,不算"点不到" */ + var det=el.closest('details'); + if(det&&!det.open&&!det.querySelector('summary').contains(el)) return; + /*SCOPE*/ + if(el.ownerSVGElement) return; + if(el.closest('.markdown-body')&&getComputedStyle(el).display==='inline') return; + scanned++; + var c=getComputedStyle(el); + var r=el.getBoundingClientRect(); + /* try to bring it into view first: unreachable means a real dead button */ + if(r.top<0||r.bottom>vh||r.left<0||r.right>vw){ + /* 判"够不够得到"必须按手指能做的事来:逐层把可滚祖先滚到把元素居中。 + scrollIntoView 不算——实测它在这种「外层 overflow:hidden + 内层 auto」的嵌套里 + 根本不滚(元素原地不动),会把 59 个划得到的控件误报成 offscreen。 + overflow:hidden 的祖先手指也划不动,所以只滚 auto/scroll 的层。 */ + (function reveal(node){ + var a=node.parentElement; + while(a&&a!==document.documentElement){ + var cc=getComputedStyle(a); + if(/auto|scroll/.test(cc.overflowY)&&a.scrollHeight>a.clientHeight+2){ + var ab=a.getBoundingClientRect(), rb2=node.getBoundingClientRect(); + a.scrollTop+= (rb2.top+rb2.height/2)-(ab.top+ab.height/2); + } + if(/auto|scroll/.test(cc.overflowX)&&a.scrollWidth>a.clientWidth+2){ + var ab2=a.getBoundingClientRect(), rb3=node.getBoundingClientRect(); + a.scrollLeft+= (rb3.left+rb3.width/2)-(ab2.left+ab2.width/2); + } + a=a.parentElement; + } + })(el); + r=el.getBoundingClientRect(); + } + var w=r.width,h=r.height; + var out={el:nm(el),p:path(el),t:label(el),w:Math.round(w),h:Math.round(h)}; + if(w<2||h<2){ out.why='zero'; bad.push(out); return; } + if(c.pointerEvents==='none'||c.pointerEvents==='hidden'){ out.why='pointer-none'; bad.push(out); return; } + if(w=32 且 label 两个方向都到下限才算合格,否则照报 tiny */ + var pass=false; + /* 本层对复选/单选框的口径是 30px(见 mobile.css 24.11),整行/label 才是命中区 */ + if((el.type==='checkbox'||el.type==='radio')&&w>=30&&h>=30){ + /* 命中区常常由 label 或整行提供(本层 24.11 就是按"整行可点"写的): + 向上找第一个真正可点的祖先,它到下限就算合格 */ + var n=el, d=0; + while(n&&n!==document.body&&d++<5){ + var clickable=n.tagName==='LABEL'||n.hasAttribute('onclick')||getComputedStyle(n).cursor==='pointer'; + if(clickable){var nb=n.getBoundingClientRect(); if(nb.width>=MIN&&nb.height>=MIN){pass=true;break;}} + n=n.parentElement; + } + } + if(!pass){ out.why='tiny'; bad.push(out); return; } + } + var cx=r.left+w/2, cy=r.top+h/2; + if(cx<0||cy<0||cx>vw||cy>vh){ + var cl=clipAncestor(el), sx=scrollAncestor(el,'x'), sy=scrollAncestor(el,'y'); + out.why='offscreen'; out.clip=cl?nm(cl):null; out.swipes=[sx?'x':null,sy?'y':null].filter(Boolean).join(''); + bad.push(out); return; + } + var hitTop=document.elementFromPoint(cx,cy); + var ok=false, cur=hitTop; + while(cur){ if(cur===el){ok=true;break} cur=cur.parentElement; } + if(!ok){ + cur=el; + while(cur&&!ok){ if(cur.contains&&hitTop&&cur.contains(hitTop)) ok=true; cur=cur.parentElement; } + } + if(!ok&&hitTop){ + /* delegation: a handler on an ancestor is a legitimate target */ + var a=el.parentElement, d=0; + while(a&&d++<4){ if(a.contains(hitTop)){ok=true;break} a=a.parentElement; } + } + if(!ok){ + out.why='covered'; + out.blocker=hitTop?nm(hitTop):'none'; + out.blockerPath=hitTop?path(hitTop):''; + var of=overFixed(r); if(of) out.blockerFixed=of; + bad.push(out); return; + } + if(el.id) chromeSeen[el.id]=1; + }); + + var scr=document.scrollingElement||document.documentElement; + return { + vw:vw,vh:vh,scanned:scanned,bad:bad, + doc:{h:Math.round(scr.scrollHeight),clientH:scr.clientHeight,canScrollY:scr.scrollHeight>scr.clientHeight+2, + bodyOverflowY:getComputedStyle(document.body).overflowY,htmlTouch:getComputedStyle(document.documentElement).touchAction}, + chrome:{tabbar:!!document.querySelector('#m-tabbar'),sheet:!!document.querySelector('#m-sheet'), + menu:!!document.querySelector('.m-menu-btn'),more:!!document.querySelector('.m-more-btn'), + chatbar:!!document.querySelector('#m-chat-bar'),scrim:!!document.querySelector('#m-scrim')}, + locale:(document.documentElement.getAttribute('lang')||'') + }; +})()`; + +/* DETECT 默认扫"当前页 + 全局固定件"。遮罩展开时页面元素会被 scrim 判成 covered, + 所以扫抽屉/面板要限定作用域,否则全是假红。 */ +const detectFor = scope => DETECT.replace('/*SCOPE*/', + scope ? `if(!el.closest(${JSON.stringify(scope)})) return;` : ''); + +async function openPage(hash) { + await ev(`location.hash=${JSON.stringify(hash)}`); + await sleep(2600); +} + +const host = new URL(BASE).hostname; +if (COOKIE) await send('Network.setCookie', { name: 'auth_token', value: COOKIE, domain: host, path: '/' }, sessionId); +await send('Page.navigate', { url: BASE + '/' }, sessionId); +await sleep(3500); + +/* The login card is a phone surface too — check it before authenticating. */ +const login = await ev(DETECT); +if (!COOKIE) { + await ev(`(()=>{const u=document.getElementById('login-username'),p=document.getElementById('login-password');if(u&&p){u.value=${JSON.stringify(USER)};p.value=${JSON.stringify(PASS)};document.getElementById('login-form').requestSubmit();}})()`); + await sleep(8000); +} else { + console.log('using the session cookie supplied via --cookie; no password was sent'); +} +const who = await ev(`(()=>{const o=document.getElementById('login-overlay');return o?getComputedStyle(o).display:'none'})()`); +if (who !== 'none') { console.error('login did not take (overlay still displayed):', who); process.exit(3); } + +const report = { login, pages: {}, chromeTaps: [] }; +let grand = {}; +const bump = k => { grand[k] = (grand[k] || 0) + 1; }; + +const log = (o, k) => { (o.bad || []).forEach(b => bump(b.why)); }; + +for (const pg of PAGES) { + await openPage(pg); + const r = await ev(DETECT); + if (!r || r.__err) { console.log(pg, 'DETECT ERROR', r && r.__err); continue; } + report.pages[pg] = r; + log(r); + const n = r.bad.length; + console.log(`${pg.padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(n).padStart(3)} ${n ? '' : ' OK'}`); + r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`)); + const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); + fs.writeFileSync(`${OUT}/${pg}.png`, Buffer.from(shot.data, 'base64')); +} + +/* ---------------- overlays: 抽屉与面板展开后的按钮 ---------------- */ +const OVERLAYS = [ + { name: 'sheet', route: 'dashboard', open: `CSAMobile.openSheet()`, scope: '#m-sheet' }, + { name: 'drawer', route: 'dashboard', open: `CSAMobile.openDrawer()`, scope: '#main-sidebar' }, + { name: 'chat-drawer', route: 'chat', open: `CSAMobile.openChatDrawer()`, scope: '#conversation-sidebar' }, +]; +for (const o of OVERLAYS) { + await openPage(o.route); + await ev(`(()=>{try{${o.open}}catch(e){}})()`); + await sleep(900); /* 等开合动画结束,动画中量到的是位移中的盒子 */ + const r = await ev(detectFor(o.scope)); + if (!r || r.__err) { console.log(o.name, 'DETECT ERROR', r && r.__err); continue; } + report.pages[o.name] = r; + log(r); + console.log(`${('[' + o.name + ']').padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(r.bad.length).padStart(3)} ${r.bad.length ? '' : ' OK'}`); + r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`)); + await ev(`CSAMobile.closeSheet();CSAMobile.closeDrawer();CSAMobile.closeChatDrawer();`); +} + +/* ---------------- chrome finger taps ---------------- */ +/* 关闭要走用户真能点到的控件:抽屉展开后汉堡键被抽屉自己盖住, + 再点它点的是侧栏(真实用户用抽屉内的 × 或遮罩)。每步前先复位, + 否则一条失败会连锁把后面的断言全带偏。 */ +const RESET = `CSAMobile.closeDrawer();CSAMobile.closeSheet();CSAMobile.closeChatDrawer();`; +const TAPS = [ + { name: 'menu-open', sel: '.m-menu-btn', pre: RESET, expect: `document.querySelector('#main-sidebar').classList.contains('m-open')` }, + { name: 'menu-close', sel: '#main-sidebar .m-close-btn', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` }, + { name: 'menu-scrim-close', sel: '#m-scrim', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` }, + { name: 'more-open', sel: '.m-more-btn', pre: RESET, expect: `document.querySelector('#m-sheet').classList.contains('m-show')` }, + { name: 'more-close', sel: '#m-sheet .m-close-btn', pre: `${RESET}CSAMobile.openSheet();`, expect: `!document.querySelector('#m-sheet').classList.contains('m-show')` }, + { name: 'tab-hitl', sel: '#m-tabbar [data-tab=hitl]', expect: `document.querySelector('#page-hitl').classList.contains('active')` }, + { name: 'tab-tasks', sel: '#m-tabbar [data-tab=tasks]', expect: `document.querySelector('#page-tasks').classList.contains('active')` }, + { name: 'tab-dashboard', sel: '#m-tabbar [data-tab=dashboard]', expect: `document.querySelector('#page-dashboard').classList.contains('active')` }, + { name: 'tab-chat', sel: '#m-tabbar [data-tab=chat]', expect: `document.querySelector('#page-chat').classList.contains('active')` }, + { name: 'chat-drawer-open', sel: '#m-chat-bar button:first-child', expect: `document.querySelector('#conversation-sidebar').classList.contains('m-open')` }, + { name: 'chat-drawer-close', sel: '#conversation-sidebar .m-close-btn', expect: `!document.querySelector('#conversation-sidebar').classList.contains('m-open')` }, +]; + +if (CHROME) { + await openPage('chat'); + for (const t of TAPS) { + if (t.pre) await ev(`(()=>{try{${t.pre}}catch(e){}})()`); + await sleep(320); + const box = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(t.sel)});if(!e)return null;const r=e.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2,w:r.width,h:r.height,shown:getComputedStyle(e).display!=='none'}})()`); + if (!box || !box.shown) { report.chromeTaps.push({ name: t.name, ok: false, why: 'absent' }); console.log(`tap ${t.name.padEnd(20)} ABSENT`); continue; } + events.length = 0; + await fingerTap(box.x, box.y); + const passed = await ev(`(()=>{try{return !!(${t.expect})}catch(e){return false}})()`); + const err = events.includes('Runtime.exceptionThrown'); + report.chromeTaps.push({ name: t.name, ok: !!passed && !err, state: passed, err }); + console.log(`tap ${t.name.padEnd(20)} ${passed && !err ? 'ok' : 'FAIL'} (state=${passed} jsErr=${err})`); + } + /* language round-trip: the injected chrome must re-label itself */ + await ev(`window.changeLanguage && window.changeLanguage('en-US')`); + await sleep(2500); + const en = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`); + report.langEn = en; + console.log('en-US chrome labels:', JSON.stringify(en)); + await ev(`window.changeLanguage && window.changeLanguage('ru-RU')`); + await sleep(2500); + const ru = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`); + report.langRu = ru; + console.log('ru-RU chrome labels:', JSON.stringify(ru)); + await ev(`window.changeLanguage && window.changeLanguage('zh-CN')`); + await sleep(2000); +} + +fs.writeFileSync(JSON_OUT, JSON.stringify(report, null, 2)); + +const chromeFail = report.chromeTaps.filter(t => !t.ok).length; +const total = Object.values(grand).reduce((a, b) => a + b, 0); +console.log(`\n=========== MOBILE TAP AUDIT @${W}x${H} ===========`); +console.log(`pages ${Object.keys(report.pages).length}`); +console.log(`covered (occluded) ${grand.covered || 0}`); +console.log(`offscreen ${grand.offscreen || 0}`); +console.log(`zero-size ${grand.zero || 0}`); +console.log(`pointer-events:none ${grand['pointer-none'] || 0}`); +console.log(`tap target < ${MIN_TAP}px ${grand.tiny || 0}`); +console.log(`chrome taps failed ${chromeFail}`); +console.log(`report ${JSON_OUT}`); + +/* Ratchet: the钉死的 baseline may only go down. */ +let bl = null; +try { bl = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); } catch { } +let ratchetFail = false; +if (bl) { + for (const [k, v] of Object.entries(grand)) { + if ((bl[k] ?? 0) < v) { console.log(`RATCHET FAIL ${k}: baseline ${bl[k]} -> now ${v}`); ratchetFail = true; } + } + for (const k of Object.keys(bl)) { + if (k.startsWith('page:')) { + const name = k.slice(5), now = (report.pages[name] || {}).bad?.length ?? 0; + if (bl[k] < now) { console.log(`RATCHET FAIL ${name}: baseline ${bl[k]} -> now ${now}`); ratchetFail = true; } + } + } + if (chromeFail > (bl.chromeFail ?? 0)) { console.log(`RATCHET FAIL chrome: ${bl.chromeFail} -> ${chromeFail}`); ratchetFail = true; } +} else { + const fresh = { ...grand }; + for (const [p, r] of Object.entries(report.pages)) fresh['page:' + p] = r.bad.length; + fresh.chromeFail = chromeFail; + fresh.note = 'generated by scripts/mobile-tap-audit.mjs; numbers may only decrease'; + fs.writeFileSync(BASELINE, JSON.stringify(fresh, null, 2)); + console.log(`baseline written -> ${BASELINE}`); +} + +/* 退出码口径(写死,别每次靠人判断): + - 够不到 / 被遮挡 / 零尺寸 / pointer-events:none —— 任何一处都是硬失败,控件点不到没有"存量债"可言; + - 自家注入的抽屉、面板、会话抽屉里的按钮 —— 必须 0,不许留债; + - 桌面遗留控件的小命中区 —— 走基线只降不升,超基线即红。 */ +const hard = (grand.covered || 0) + (grand.offscreen || 0) + (grand.zero || 0) + (grand['pointer-none'] || 0); +const ownChrome = ['sheet', 'drawer', 'chat-drawer'].reduce((n, k) => n + ((report.pages[k] || {}).bad || []).length, 0); +const fail = hard || ownChrome || chromeFail || ratchetFail; +console.log(`\n判定: 硬缺陷 ${hard} | 自家外壳 ${ownChrome} | 点击失败 ${chromeFail} | 基线回潮 ${ratchetFail ? '是' : '否'} -> ${fail ? 'FAIL' : 'PASS'}`); +process.exit(fail ? 1 : 0); diff --git a/scripts/mobile-tap-baseline.json b/scripts/mobile-tap-baseline.json new file mode 100644 index 00000000..8b831cd8 --- /dev/null +++ b/scripts/mobile-tap-baseline.json @@ -0,0 +1 @@ +{"tiny": 3, "page:dashboard": 0, "page:chat": 0, "page:projects": 0, "page:vulnerabilities": 0, "page:tasks": 0, "page:workflows": 0, "page:asset-overview": 0, "page:asset-library": 1, "page:info-collect": 0, "page:hitl": 0, "page:tool-guard": 2, "page:webshell": 0, "page:c2-listeners": 0, "page:c2-sessions": 0, "page:c2-tasks": 0, "page:c2-payloads": 0, "page:c2-events": 0, "page:c2-profiles": 0, "page:chat-files": 0, "page:mcp-monitor": 0, "page:mcp-management": 0, "page:knowledge-management": 0, "page:knowledge-retrieval-logs": 0, "page:roles-management": 0, "page:platform-rbac": 0, "page:skills-monitor": 0, "page:skills-management": 0, "page:agents-management": 0, "page:settings": 0, "page:audit-log": 0, "page:monitor": 0, "page:sheet": 0, "page:drawer": 0, "page:chat-drawer": 0, "chromeFail": 0, "note": "generated by scripts/mobile-tap-audit.mjs; numbers may only decrease"} \ No newline at end of file diff --git a/web/static/css/api-docs-mobile.css b/web/static/css/api-docs-mobile.css new file mode 100644 index 00000000..dcda8361 --- /dev/null +++ b/web/static/css/api-docs-mobile.css @@ -0,0 +1,301 @@ +/* + * CyberStrikeAI — API docs page (/api-docs) on phones. + * Separate template from index.html, so it needs its own adaptation layer. + * Loaded after the template's inline + +
diff --git a/web/templates/index.html b/web/templates/index.html index 254693bc..a7bd1c61 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2,7 +2,15 @@ - + + + + + + + + + CyberStrikeAI @@ -36,6 +44,8 @@ + + @@ -1257,7 +1267,7 @@