/** * v2 admin capture — stricter redaction and deeper navigation. * * - Masks customer domains (anything not in the brand allowlist). * - Masks customer-shaped usernames (anything not in the system allowlist). * - Masks input value attributes (the v1 only walked text nodes). * - Captures Settings sub-tabs (System, Services, Mail, DNS, Network & SSL, * Security) since those are where LiteLLM URL / model / key likely live. * * Read-only. Never clicks save/apply/restart/delete. */ import { chromium, type Page } from 'playwright'; import { mkdir } from 'node:fs/promises'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const OUT_DIR = resolve(__dirname, '../../src/assets/screenshots/whp'); function need(name: string): string { const v = process.env[name]; if (!v) throw new Error(`missing env: ${name}`); return v; } const BASE = need('WHP_BASE'); const USER = need('WHP_ADMIN_USER'); const PASS = need('WHP_ADMIN_PASS'); const HIDE_CSS = `.navbar-text, .brand-full { visibility: hidden !important; }`; async function login(page: Page) { await page.goto(`${BASE}/login.php`, { waitUntil: 'domcontentloaded' }); await page.fill('input[name="user"]', USER); await page.fill('input[name="password"]', PASS); await page.click('button[type="submit"]'); await page.waitForLoadState('networkidle'); } async function redact(page: Page) { await page.addStyleTag({ content: HIDE_CSS }); await page.evaluate(() => { // ---- Mask all secret-shaped input values ---- const secretInputSel = [ 'input[type="password"]', 'input[name*="key" i]', 'input[name*="token" i]', 'input[name*="secret" i]', 'input[name*="api" i]', ]; for (const sel of secretInputSel) { document.querySelectorAll(sel).forEach((el) => { if (el.value) el.value = '████████████████'; }); } // ---- Brand allowlist ---- const BRAND_SUFFIXES = [ 'anhonesthost.com', 'anhonesthost.net', 'anhonesthost.io', 'anhh.co', 'cloud-hosting.io', 'example.com', 'example.org', 'example.net', ]; // ---- System users to keep visible (others get masked) ---- const SYSTEM_USERS_ARR = ['root', 'admin', 'whp', 'haproxy', 'apache', 'nginx', 'newuser']; // ---- Text-node swaps ---- const swaps: [RegExp, string][] = [ // Server / mail / nameserver hostnames in our infra [/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '.cloud-hosting.io'], [/mail\d+\.cloud-hosting\.io/gi, '.cloud-hosting.io'], [/whp\d+(-[a-z0-9]+)?\b/gi, ''], [/WHP\d+(-[A-Z0-9]+)?\b/g, ''], // IPs [/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, ''], // Home dirs [/\/docker\/users\/[a-z0-9-]+/g, '/docker/users/'], // Common secret shapes [/sk-[A-Za-z0-9_-]{20,}/g, ''], [/sk_(test|live)_[A-Za-z0-9]{20,}/g, ''], [/Bearer\s+[A-Za-z0-9._-]{20,}/g, 'Bearer '], [/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g, ''], // AI provider URLs [/https?:\/\/[^\s"'<>]*litellm[^\s"'<>]*/gi, ''], [/https?:\/\/[^\s"'<>]*\.anhonesthost\.(net|com|io)[^\s"'<>]*/gi, ''], // Model family identifiers [/(claude|gpt|llama|mistral|gemini)[a-z0-9._-]*-\d[a-z0-9.\-]*/gi, ''], // Bichon / Coraza / haproxy internal endpoints [/https?:\/\/[^\s"'<>]*\b(bichon|coraza-spoa|haproxy-manager)[^\s"'<>]*/gi, ''], // root → admin (we don't expose which UNIX user has super admin) [/Welcome, root\b/g, 'Welcome, admin'], [/(User:\s*)root\b/g, '$1admin'], [/(Home Directory:\s*)\/root\b/g, '$1/'], // Standalone 'root' (whole-word, not preceded by / or . — so paths like // /root/foo and references like .root stay untouched). [/(^|[^/.\w])root\b/g, '$1admin'], ]; // ---- Walk text nodes ---- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const nodes: Text[] = []; let n: Node | null = walker.nextNode(); while (n) { nodes.push(n as Text); n = walker.nextNode(); } for (const node of nodes) { let v = node.nodeValue ?? ''; // Mask non-brand domain names (basic pattern: word.tld) v = v.replace(/\b([a-z0-9][a-z0-9-]{0,62}\.)+[a-z]{2,24}\b/gi, function (m) { const h = m.toLowerCase(); let brand = false; for (const s of BRAND_SUFFIXES) { if (h === s || h.endsWith('.' + s)) { brand = true; break; } } return brand ? m : ''; }); // Apply other swaps for (const [re, replacement] of swaps) v = v.replace(re, replacement); if (v !== node.nodeValue) node.nodeValue = v; } // ---- Mask sensitive content in input values ---- document.querySelectorAll('input[type="text"], input[type="url"], input[type="email"], input:not([type])').forEach((el) => { if (!el.value) return; const v = el.value; let nv = v.replace(/\b([a-z0-9][a-z0-9-]{0,62}\.)+[a-z]{2,24}\b/gi, function (m) { const h = m.toLowerCase(); let brand = false; for (const s of BRAND_SUFFIXES) { if (h === s || h.endsWith('.' + s)) { brand = true; break; } } return brand ? m : ''; }); // Server / mail / NS hostnames nv = nv.replace(/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '.cloud-hosting.io'); nv = nv.replace(/mail\d+\.cloud-hosting\.io/gi, '.cloud-hosting.io'); nv = nv.replace(/ns[12]\.whp\d+\.cloud-hosting\.io/gi, 'ns..cloud-hosting.io'); // IPv4 — skip the well-known public resolvers / RFC1918 examples nv = nv.replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, function (ip) { const pub = new Set(['1.1.1.1', '1.0.0.1', '8.8.8.8', '8.8.4.4', '9.9.9.9', '149.112.112.112', '208.67.222.222', '208.67.220.220']); if (pub.has(ip)) return ip; if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip)) return ip; // private nets, fine return ''; }); if (nv !== v) el.value = nv; }); // ---- Mask customer usernames in table cells ---- // Heuristic: find table cells under a header containing 'user', 'username', or 'target' (case-insensitive) document.querySelectorAll('table').forEach((tbl) => { const headers = Array.from(tbl.querySelectorAll('thead th, thead td')).map(th => (th.textContent || '').trim().toLowerCase()); const userCols: number[] = []; for (let i = 0; i < headers.length; i++) { if (/^(user(name)?|target|owner|account)$/.test(headers[i])) userCols.push(i); } if (userCols.length === 0) return; const rows = tbl.querySelectorAll('tbody tr'); for (let r = 0; r < rows.length; r++) { const cells = rows[r].querySelectorAll('td'); for (const idx of userCols) { const cell = cells[idx]; if (!cell) continue; const txt = (cell.textContent || '').trim(); if (!txt) continue; let isSystem = false; for (const u of SYSTEM_USERS_ARR) { if (u === txt) { isSystem = true; break; } } if (!isSystem) cell.textContent = ''; } } }); }); } async function shot(page: Page, id: string) { await page.waitForLoadState('networkidle'); await page.waitForTimeout(500); await redact(page); const path = resolve(OUT_DIR, `${id}.png`); await page.screenshot({ path, fullPage: false }); console.log(`captured ${id}`); } async function main() { await mkdir(OUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }); const page = await ctx.newPage(); try { await login(page); // Server Settings — click each tab await page.goto(`${BASE}/index.php?page=server-settings`); await page.waitForLoadState('networkidle'); await shot(page, 'admin-srvset-system'); for (const tab of ['services', 'mail', 'dns', 'network', 'security']) { const trigger = page.locator(`[data-bs-target="#tab-${tab}"]`).first(); if (await trigger.count() === 0) { console.log('tab trigger not found:', tab); continue; } await trigger.click().catch(()=>{}); await page.waitForTimeout(1200); await shot(page, `admin-srvset-${tab}`); } // Re-shoot the previously PII-heavy pages with v2 redaction const rerun = [ { id: 'admin-coraza', path: '/index.php?page=coraza-rules' }, { id: 'admin-monitor-admin', path: '/index.php?page=ai-monitor' }, { id: 'admin-ignore-rules', path: '/index.php?page=ai-monitor-ignore-rules' }, { id: 'admin-user-mgmt', path: '/index.php?page=user-management' }, { id: 'admin-user-resources', path: '/index.php?page=user-resources' }, { id: 'admin-issues', path: '/index.php?page=issues' }, { id: 'admin-suspensions', path: '/index.php?page=account-suspensions' }, { id: 'admin-disk-usage', path: '/index.php?page=disk-usage' }, { id: 'admin-docker', path: '/index.php?page=docker-management' }, { id: 'admin-valkey', path: '/index.php?page=valkey-admin' }, { id: 'admin-updates', path: '/index.php?page=update-management' }, { id: 'admin-container-boot', path: '/index.php?page=container-boot' }, { id: 'admin-site-audit', path: '/index.php?page=site-audit' }, { id: 'admin-delegated', path: '/index.php?page=delegated-users' }, ]; for (const r of rerun) { await page.goto(`${BASE}${r.path}`); await page.waitForLoadState('networkidle'); await shot(page, r.id); } } finally { await browser.close(); } } main().catch(e => { console.error(e); process.exit(1); });