UNPKG

4bnode

Version:

4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.

154 lines (143 loc) 6.97 kB
#!/usr/bin/env node // Audit the /_dev dashboard exposure across 4bnode apps (read-only). // // node scripts/audit-dev-surface.mjs <dir> [--since=2026-06-07] // // Scans <dir> recursively for 4bnode apps (any folder containing // .4bnode/dev-api.js) and reports, per app: // • whether the /_dev mount is gated on NODE_ENV (production-safe) // • whether dev-api.js carries the loopback network guard // • whether a .passkey exists and whether it is a known-weak PIN // • route/source files modified on/after --since, and any that contain // code-execution / cryptominer signatures (possible injected backdoor) // // Nothing is modified. Rotate weak/shared passkeys with `npx 4bnode passkey`. import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; const args = process.argv.slice(2); const root = path.resolve(args.find((a) => !a.startsWith('--')) || '.'); const sinceArg = (args.find((a) => a.startsWith('--since=')) || '').split('=')[1]; const since = sinceArg ? new Date(sinceArg) : null; const sha = (s) => crypto.createHash('sha256').update(s).digest('hex'); // Common weak PINs operators reuse. Hashes compared against .4bnode/.passkey. const WEAK_PINS = ['123456', '000000', '111111', '121212', '123123', '654321', '666666', '999999', '112233', '159753', '147258', '101010', '696969', '777777', '123321', '789456', '000001', '222222', '555555', '888888', '123654', '098765']; const WEAK_HASHES = new Map(WEAK_PINS.map((p) => [sha(p), p])); // Signatures of code execution / miners that should never appear in generated routes. const BACKDOOR_PATTERNS = [ [/child_process|\brequire\(\s*['"]child_process['"]\s*\)/, 'child_process import'], [/\bexec(Sync)?\s*\(/, 'exec() call'], [/\bspawn(Sync)?\s*\(/, 'spawn() call'], [/\beval\s*\(/, 'eval()'], [/new\s+Function\s*\(/, 'new Function()'], [/Buffer\.from\([^)]*,\s*['"]base64['"]\)/, 'base64-decoded payload'], [/(curl|wget)\s+[^\n|]*\|\s*(sh|bash)/, 'pipe-to-shell download'], [/xmrig|minerd|stratum\+tcp|coinhive|cryptonight|nanopool|supportxmr|nicehash/i, 'cryptominer signature'], [/\/dev\/tcp\//, 'reverse-shell /dev/tcp'], ]; const C = { red: (s) => `\x1b[31m${s}\x1b[0m`, yel: (s) => `\x1b[33m${s}\x1b[0m`, grn: (s) => `\x1b[32m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` }; function findApps(dir, depth = 0, acc = []) { if (depth > 4) return acc; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; } if (entries.some((e) => e.isDirectory() && e.name === '.4bnode') && fs.existsSync(path.join(dir, '.4bnode', 'dev-api.js'))) { acc.push(dir); } for (const e of entries) { if (e.isDirectory() && e.name !== 'node_modules' && e.name !== '.git' && e.name !== '.4bnode') { findApps(path.join(dir, e.name), depth + 1, acc); } } return acc; } function scanSourceFiles(appDir) { const targets = ['index.js', 'src']; const files = []; const walk = (p) => { let st; try { st = fs.statSync(p); } catch { return; } if (st.isDirectory()) { if (path.basename(p) === 'node_modules' || path.basename(p) === '.git') return; for (const c of fs.readdirSync(p)) walk(path.join(p, c)); } else if (p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs')) { files.push(p); } }; for (const t of targets) walk(path.join(appDir, t)); const findings = []; for (const f of files) { let content, mtime; try { content = fs.readFileSync(f, 'utf8'); mtime = fs.statSync(f).mtime; } catch { continue; } const rel = path.relative(appDir, f); const hits = []; const lines = content.split('\n'); for (const [re, label] of BACKDOOR_PATTERNS) { for (let i = 0; i < lines.length; i++) { if (re.test(lines[i])) hits.push(`${rel}:${i + 1}${label}`); } } const recent = since && mtime >= since; if (hits.length || recent) findings.push({ rel, mtime, hits, recent }); } return findings; } function auditApp(appDir) { const name = path.basename(appDir); const issues = []; // 1. Mount gating const idx = path.join(appDir, 'index.js'); if (fs.existsSync(idx)) { const c = fs.readFileSync(idx, 'utf8'); const mountIdx = Math.max(c.indexOf('app.use("/_dev"'), c.indexOf("app.use('/_dev'")); if (mountIdx !== -1) { const gateIdx = c.search(/NODE_ENV\s*!==\s*['"]production['"]/); const gated = gateIdx !== -1 && gateIdx < mountIdx; if (!gated) issues.push([C.red('CRITICAL'), '/_dev is mounted WITHOUT a NODE_ENV production gate']); } } // 2. Loopback guard in dev-api.js const devApi = path.join(appDir, '.4bnode', 'dev-api.js'); const devApiSrc = fs.existsSync(devApi) ? fs.readFileSync(devApi, 'utf8') : ''; if (devApiSrc && !devApiSrc.includes('isLoopbackRequest')) { issues.push([C.red('CRITICAL'), 'dev-api.js has NO loopback network guard (reachable off-host)']); } // 3. Passkey const pkFile = path.join(appDir, '.4bnode', '.passkey'); if (!fs.existsSync(pkFile)) { issues.push([C.red('CRITICAL'), 'NO .passkey file — dashboard auth is open (anyone gets in)']); } else { const h = fs.readFileSync(pkFile, 'utf8').trim(); if (WEAK_HASHES.has(h)) { issues.push([C.red('CRITICAL'), `weak/guessable passkey "${WEAK_HASHES.get(h)}" — rotate with \`npx 4bnode passkey\``]); } } // 4. Backdoor / recent-change scan const findings = scanSourceFiles(appDir); const backdoors = findings.filter((f) => f.hits.length); const recents = findings.filter((f) => f.recent && !f.hits.length); console.log(`\n${C.bold(name)} ${C.dim(appDir)}`); if (!issues.length && !backdoors.length) { console.log(` ${C.grn('✔')} no exposure issues found`); } for (const [sev, msg] of issues) console.log(` ${sev} ${msg}`); for (const f of backdoors) { console.log(` ${C.red('BACKDOOR?')} ${f.rel} ${C.dim('(modified ' + f.mtime.toISOString() + ')')}`); for (const h of f.hits) console.log(` ${C.yel(h)}`); } if (recents.length) { console.log(` ${C.yel('REVIEW')} ${recents.length} source file(s) modified since ${sinceArg}:`); for (const f of recents) console.log(` ${f.rel} ${C.dim(f.mtime.toISOString())}`); } return { name, critical: issues.filter((i) => i[0].includes('CRITICAL')).length + backdoors.length }; } console.log(C.bold(`\n4bnode /_dev exposure audit — ${root}`)); if (since) console.log(C.dim(`Flagging source changed on/after ${sinceArg}`)); const apps = findApps(root); if (!apps.length) { console.log(C.yel('\nNo 4bnode apps (.4bnode/dev-api.js) found under that path.')); process.exit(0); } let totalCritical = 0; for (const app of apps) totalCritical += auditApp(app).critical; console.log(`\n${C.bold('Summary:')} ${apps.length} app(s) scanned, ${totalCritical ? C.red(totalCritical + ' critical finding(s)') : C.grn('0 critical findings')}.`); process.exit(totalCritical ? 1 : 0);