#!/usr/bin/env node 'use strict'; /** * 军歌嘹亮 · 静态审计与自测流水线 * * 用法: * node tools/audit-project.js # 人类可读报告 * node tools/audit-project.js --json # 机器可读,便于 CI / 迭代 * * 退出码: 0 = 无错误(可能有警告); 1 = 存在错误 */ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); // tools/ 是检查器自身,内部必然包含 importObject 等模式字符串, // 当成业务代码审计只会产生假阳性;.zcode/ 是工具链运行产物。 const IGNORE_DIRS = new Set(['.git', 'node_modules', 'unpackage', '.hbuilderx', '.trae', 'git', 'tools', '.zcode']); // P2-1: 已被 .gitignore 忽略的构建产物不参与编码审计(hx_page.html 的 UTF-8 BOM 是 HBuilderX 生成器行为,非源码问题)。 const IGNORE_FILES = new Set(['hx_page.html']); const SOURCE_EXT = new Set(['.vue', '.js', '.ts', '.json', '.scss', '.css', '.md', '.html']); const JSON_OUT = process.argv.includes('--json'); const errors = []; const warnings = []; const stats = {}; const seen = new Set(); function push(list, section, msg, file, line) { const key = `${section}|${file}|${line}|${msg}`; if (seen.has(key)) return; seen.add(key); list.push({ section, msg, file, line }); } const err = (s, m, f, l) => push(errors, s, m, f, l); const warn = (s, m, f, l) => push(warnings, s, m, f, l); const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/'); const lineAt = (src, idx) => src.slice(0, idx).split('\n').length; function walk(dir, out = []) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; } for (const e of entries) { if (e.isDirectory()) { if (IGNORE_DIRS.has(e.name)) continue; walk(path.join(dir, e.name), out); } else if (e.isFile()) { if (IGNORE_FILES.has(e.name)) continue; out.push(path.join(dir, e.name)); } } return out; } /** uniCloud 官方内置云函数:不计入业务审计 */ const STOCK_CLOUD_FNS = new Set([ 'uni-stat-receiver', 'uni-stat-cron', 'uni-analyse-searchhot', 'uni-portal', 'uni-upgrade-center', 'uni-sms-co', 'uni-id-co', 'uni-captcha-co', 'uni-media-library-co', 'uni-pay-co', 'uni-cms-unlock-callback', ]); /** 第三方 / 官方模块:问题降级为警告,不作为阻塞项 */ function isVendor(r) { if (r.startsWith('pages3/uni_modules/')) return true; if (r.startsWith('uniCloud-alipay/cloudfunctions/common/')) return true; if (r.startsWith('js_sdk/')) return true; const cf = r.match(/cloudfunctions\/([\w-]+)\//); if (cf && STOCK_CLOUD_FNS.has(cf[1])) return true; const m = r.match(/^uni_modules\/([^/]+)\//); if (m && !['uni-cms-article', 'hb-comment'].includes(m[1])) return true; return false; } /** 是否属于云函数源码(用于跳过自调用检查) */ const isCloudFnPath = (r) => /\/uniCloud\/cloudfunctions\//.test(r) || r.startsWith('uniCloud-alipay/cloudfunctions/'); // ───────────────────────────────────────────── // 1. 文件编码 // ───────────────────────────────────────────── function sectionEncoding(files) { let scanned = 0; for (const f of files) { if (!SOURCE_EXT.has(path.extname(f).toLowerCase())) continue; const r = rel(f); const buf = fs.readFileSync(f); if (buf.length === 0) continue; scanned++; const utf16Bom = buf.length >= 2 && ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff)); const utf8Bom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf; const n = Math.min(buf.length, 8192); let zeros = 0; for (let i = 0; i < n; i++) if (buf[i] === 0) zeros++; const report = (msg) => (isVendor(r) ? warn : err)('编码', msg, r, 1); if (utf16Bom) { report('文件是 UTF-16 (带 BOM),编辑器/终端下必然乱码'); continue; } if (zeros > n * 0.1) { report(`疑似 UTF-16 裸编码(零字节 ${zeros}/${n}),应转为 UTF-8`); continue; } if (utf8Bom) { warn('编码', '存在 UTF-8 BOM(建议移除)', r, 1); continue; } try { new TextDecoder('utf-8', { fatal: true }).decode(buf); } catch { report('不是合法 UTF-8 字节序列(很可能是 GBK 或二次编码损坏)'); } } stats.encodedFiles = scanned; } // ───────────────────────────────────────────── // 2. 页面注册表 // ───────────────────────────────────────────── function sectionPages() { let pagesJson; try { pagesJson = JSON.parse(fs.readFileSync(path.join(ROOT, 'pages.json'), 'utf8')); } catch (e) { err('页面', `pages.json 无法解析: ${e.message}`, 'pages.json', 1); return { routes: new Set(), tabBar: new Set() }; } const routes = new Set(); const tabBar = new Set(); for (const p of pagesJson.pages || []) routes.add(p.path); for (const sp of pagesJson.subPackages || []) { const root = String(sp.root || '').replace(/\/+$/, ''); for (const p of sp.pages || []) routes.add(root ? `${root}/${p.path}` : p.path); } const tabList = (pagesJson.tabBar && pagesJson.tabBar.list) || []; for (const t of tabList) { tabBar.add(t.pagePath); if (!routes.has(t.pagePath)) { err('页面', `tabBar 页面未在 pages/subPackages 中注册: ${t.pagePath}`, 'pages.json', 1); } for (const key of ['iconPath', 'selectedIconPath']) { const icon = t[key]; if (!icon) continue; const abs = path.join(ROOT, icon.replace(/^\//, '')); if (!fs.existsSync(abs)) { err('页面', `tabBar「${t.text}」的 ${key} 图标文件不存在: ${icon}`, 'pages.json', 1); } } } for (const r of routes) { if (!fs.existsSync(path.join(ROOT, r + '.vue'))) { err('页面', `pages.json 注册了页面,但文件不存在: ${r}.vue`, 'pages.json', 1); } } stats.routes = routes.size; stats.tabBar = tabBar.size; return { routes, tabBar, pagesJson }; } // ───────────────────────────────────────────── // 3. 路由跳转交叉比对 // ───────────────────────────────────────────── const NAV_RE = /(?:uni\.)?(navigateTo|redirectTo|reLaunch|switchTab)\s*\(([\s\S]{0,400}?)\)/g; const URL_RE = /url\s*:\s*(['"`])([^'"`]+)\1/; /** 把 url 归一化成 pages.json 里那种根相对路径 */ function resolveRoute(raw, fileRel, routes) { const p = raw.split('?')[0].split('#')[0].trim(); if (!p) return null; if (p.startsWith('/')) return p.slice(1).replace(/\/+$/, ''); if (p.startsWith('./') || p.startsWith('../')) { const dir = path.posix.dirname(fileRel); return path.posix.normalize(path.posix.join(dir, p)).replace(/^\.\//, '').replace(/\/+$/, ''); } // 无前导斜杠:优先按根相对理解,其次按文件相对理解 if (routes.has(p)) return p; return path.posix .normalize(path.posix.join(path.posix.dirname(fileRel), p)) .replace(/^\.\//, '') .replace(/\/+$/, ''); } function sectionNavigation(files, routes, tabBar) { let checked = 0; for (const f of files) { const ext = path.extname(f).toLowerCase(); if (ext !== '.vue' && ext !== '.js') continue; const r = rel(f); if (isVendor(r)) continue; const src = fs.readFileSync(f, 'utf8'); let m; NAV_RE.lastIndex = 0; while ((m = NAV_RE.exec(src))) { const api = m[1]; const um = URL_RE.exec(m[2]); if (!um) continue; const raw = um[2]; if (raw.includes('${')) continue; // 动态拼接,静态不可判定 const target = resolveRoute(raw, r, routes); if (!target) continue; checked++; const line = lineAt(src, m.index); const isTab = tabBar.has(target); if (!routes.has(target)) { err('路由', `${api} 跳转到未注册页面: ${target}`, r, line); } else if (isTab && (api === 'navigateTo' || api === 'redirectTo')) { err('路由', `${api} 不能跳转 tabBar 页面(须用 switchTab): ${target}`, r, line); } else if (!isTab && api === 'switchTab') { err('路由', `switchTab 只能跳转 tabBar 页面: ${target}`, r, line); } } } stats.navChecked = checked; } // ───────────────────────────────────────────── // 4. 云函数导出解析 // ───────────────────────────────────────────── const JS_KEYWORDS = new Set([ 'function', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default', 'break', 'continue', 'try', 'catch', 'finally', 'throw', 'new', 'delete', 'typeof', 'instanceof', 'in', 'of', 'var', 'let', 'const', 'class', 'extends', 'super', 'this', 'null', 'true', 'false', 'undefined', 'await', 'async', 'yield', 'void', 'export', 'import', ]); function extractExports(src) { const methods = new Set(); // module.exports = { key: ..., async key(...) {} } const i = src.indexOf('module.exports'); if (i >= 0) { const start = src.indexOf('{', i); if (start >= 0) { let depth = 0; let end = -1; for (let k = start; k < src.length; k++) { const c = src[k]; if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) { end = k; break; } } } const body = src.slice(start + 1, end < 0 ? src.length : end); let d = 0; for (let j = 0; j < body.length; j++) { const c = body[j]; if (c === '{' || c === '(' || c === '[') { d++; continue; } if (c === '}' || c === ')' || c === ']') { d--; continue; } if (d !== 0) continue; if (j > 0 && !/[\s,{]/.test(body[j - 1])) continue; const m = /^(?:async\s+\*?\s*)?([A-Za-z_$][\w$]*)\s*/.exec(body.slice(j)); if (!m || JS_KEYWORDS.has(m[1])) continue; const name = m[1]; const rest = body.slice(j + m[0].length); // 方法简写:`name(...) {}` if (rest.startsWith('(')) { methods.add(name); continue; } // 键值对:只有值确实是函数才计为方法,`name: 1` 不算 if (rest.startsWith(':')) { const after = rest.slice(1).replace(/^\s+/, ''); const isFn = /^(async\s+)?function\b/.test(after) || /^(async\s*)?\(/.test(after) || /^(async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(after); if (isFn) methods.add(name); continue; } // ES6 简写属性:`name,` / `name\n}` if (rest.startsWith(',') || /^\s*}/.test(rest)) { methods.add(name); } } } } // exports.foo = / module.exports.foo = let m; const re = /(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g; while ((m = re.exec(src))) methods.add(m[1]); return methods; } function sectionCloudFunctions() { const out = new Map(); const roots = [ ['uniCloud-alipay/cloudfunctions', path.join(ROOT, 'uniCloud-alipay/cloudfunctions')], ['uni_modules', path.join(ROOT, 'uni_modules')], ['pages3/uni_modules', path.join(ROOT, 'pages3/uni_modules')], ]; const consider = (dirPath, dirRel) => { if (/[/\\]common$/.test(dirRel)) return; const objFile = path.join(dirPath, 'index.obj.js'); const plainFile = path.join(dirPath, 'index.js'); const name = path.basename(dirRel); // 普通云函数(index.js)不是云对象,只能 callFunction,不能 importObject if (!fs.existsSync(objFile)) { if (fs.existsSync(plainFile)) { out.set(name, { file: rel(plainFile), methods: null, hasBefore: false, isVendor: isVendor(rel(plainFile)) }); return; } if (!isVendor(dirRel + '/index.obj.js')) { err('云函数', '目录里既没有 index.obj.js 也没有 index.js,无法被调用', dirRel, 1); } return; } const src = fs.readFileSync(objFile, 'utf8'); out.set(name, { file: rel(objFile), methods: extractExports(src), hasBefore: /_before\s*[:(]/.test(src), isVendor: isVendor(rel(objFile)), }); }; const [envRel, envAbs] = roots[0]; let entries = []; try { entries = fs.readdirSync(envAbs, { withFileTypes: true }); } catch { err('云函数', '找不到 uniCloud-alipay/cloudfunctions 目录', envRel, 1); } for (const e of entries) { if (e.isDirectory()) consider(path.join(envAbs, e.name), `${envRel}/${e.name}`); } for (const [, base] of roots.slice(1)) { let mods = []; try { mods = fs.readdirSync(base, { withFileTypes: true }); } catch { continue; } for (const m of mods) { if (!m.isDirectory()) continue; const cfDir = path.join(base, m.name, 'uniCloud/cloudfunctions'); if (!fs.existsSync(cfDir)) continue; for (const c of fs.readdirSync(cfDir, { withFileTypes: true })) { if (!c.isDirectory()) continue; consider(path.join(cfDir, c.name), rel(path.join(cfDir, c.name))); } } } stats.cloudFunctions = out.size; return out; } // ───────────────────────────────────────────── // 5. importObject / callFunction 交叉比对 // ───────────────────────────────────────────── const IMPORT_RE = /(\w+)\s*=\s*uniCloud\.importObject\(\s*['"]([\w-]+)['"]/g; const CALLFN_RE = /callFunction\s*\(\s*\{([\s\S]{0,400}?)\}/g; const NAME_RE = /name\s*:\s*['"]([\w-]+)['"]/; const SKIP_METHODS = new Set(['then', 'catch', 'finally', 'toString', 'valueOf']); function sectionCloudCalls(files, cloudfns) { let checked = 0; for (const f of files) { const ext = path.extname(f).toLowerCase(); if (ext !== '.vue' && ext !== '.js') continue; const r = rel(f); if (isVendor(r)) continue; const src = fs.readFileSync(f, 'utf8'); // 云函数自身内部调用(this.xxx)不算跨函数调用 if (!isCloudFnPath(r)) { const vars = []; let m; IMPORT_RE.lastIndex = 0; while ((m = IMPORT_RE.exec(src))) { vars.push({ varName: m[1], fn: m[2] }); const decl = cloudfns.get(m[2]); if (!decl) { err('云调用', `importObject 引用了不存在的云函数: ${m[2]}`, r, lineAt(src, m.index)); } else if (decl.methods === null) { err('云调用', `${m[2]} 是普通云函数(index.js),不能用 importObject 调用`, r, lineAt(src, m.index)); } } for (const v of vars) { const callRe = new RegExp(`\\b${v.varName}\\.([A-Za-z_$][\\w$]*)\\s*\\(`, 'g'); let c; while ((c = callRe.exec(src))) { if (SKIP_METHODS.has(c[1])) continue; checked++; const fn = cloudfns.get(v.fn); if (!fn) continue; if (fn.methods.has(c[1])) continue; err('云调用', `${v.fn} 不存在方法 ${c[1]}()`, r, lineAt(src, c.index)); } } } CALLFN_RE.lastIndex = 0; let k; while ((k = CALLFN_RE.exec(src))) { const nm = NAME_RE.exec(k[1]); if (!nm) continue; checked++; if (!cloudfns.has(nm[1])) { err('云调用', `callFunction 调用了不存在的云函数: ${nm[1]}`, r, lineAt(src, k.index)); } } } stats.cloudCallsChecked = checked; } // ───────────────────────────────────────────── // 6. 未完成代码 // ───────────────────────────────────────────── const TODO_RE = /\b(TODO|FIXME|XXX|HACK)\b/g; function sectionTodos(files) { let count = 0; for (const f of files) { const ext = path.extname(f).toLowerCase(); if (ext !== '.vue' && ext !== '.js') continue; const r = rel(f); if (isVendor(r) || r.startsWith('tools/')) continue; const src = fs.readFileSync(f, 'utf8'); let m; TODO_RE.lastIndex = 0; while ((m = TODO_RE.exec(src))) { count++; const line = lineAt(src, m.index); warn('未完成', `${m[1]}: ${src.split('\n')[line - 1].trim().slice(0, 90)}`, r, line); } } stats.todos = count; } // ───────────────────────────────────────────── // 7. 硬编码敏感信息 // ───────────────────────────────────────────── const SECRET_RE = /\b(secret|sk|password|passwd|pwd|api[_-]?key|access[_-]?key|bucketSecret|appSecret)\b\s*[:=]\s*['"]([A-Za-z0-9+/=_\-]{16,})['"]/gi; const IP_RE = /\b((?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d))\b/g; function sectionSecrets(files) { let count = 0; for (const f of files) { const ext = path.extname(f).toLowerCase(); if (ext !== '.vue' && ext !== '.js' && ext !== '.json') continue; const r = rel(f); if (isVendor(r)) continue; const src = fs.readFileSync(f, 'utf8'); let m; SECRET_RE.lastIndex = 0; while ((m = SECRET_RE.exec(src))) { count++; warn('敏感信息', `疑似硬编码 ${m[1]}(长度 ${m[2].length}),应移入环境变量`, r, lineAt(src, m.index)); } IP_RE.lastIndex = 0; const privateOk = (ip) => { if (/^(127|192\.168|10|0)\./.test(ip)) return true; // 保留地址:广播 / 未指定 / 链路本地 / 组播,出现在 IP 校验逻辑里属正常 if (ip === '255.255.255.255' || ip === '0.0.0.0' || /^(169\.254|22[4-9]|23\d)\./.test(ip)) return true; // 形如 3.0.0.0 的版本号,每段都很小,不算硬编码业务地址 return ip.split('.').every((o) => Number(o) <= 20); }; while ((m = IP_RE.exec(src))) { if (privateOk(m[1])) continue; count++; warn('敏感信息', `硬编码公网 IP 地址: ${m[1]}`, r, lineAt(src, m.index)); } } stats.secrets = count; } // ───────────────────────────────────────────── // 8. 云函数安全基线 // ───────────────────────────────────────────── const WRITE_METHOD_RE = /\basync\s+([A-Za-z_$][\w$]*)\s*\(/g; const READONLY_HINTS = /^(get|list|query|search|count|is[A-Z]|_)/; /** * 把行注释内容替换为等量空格:长度不变,字节偏移不变, * 因此 lineAt(src, idx) 与 src.slice 仍指向同一位置。 * * 不做这层剥离时,被注释掉的 `async foo() {}` 会被 WRITE_METHOD_RE 当成 * 活方法,产生「未校验登录态」的假警报(ext-storage-co.downloadFile 就是)。 */ function blankLineComments(src) { return src.replace(/^([ \t]*)\/\/.*$/gm, (line) => ' '.repeat(line.length)); } /** * 云对象方法按"是否写库"与"是否校验登录"分类。 * 写方法缺少 checkToken 即为越权风险;读方法缺少则提示可能泄露数据。 */ function sectionCloudAuth(files, cloudfns) { let checked = 0; for (const [name, decl] of cloudfns) { if (decl.isVendor || decl.methods === null) continue; let raw; try { raw = fs.readFileSync(path.join(ROOT, decl.file), 'utf8'); } catch { continue; } const src = blankLineComments(raw); // 逐个方法切分,判断方法体内是否有写操作且无鉴权 const marks = []; let m; WRITE_METHOD_RE.lastIndex = 0; while ((m = WRITE_METHOD_RE.exec(src))) marks.push({ name: m[1], idx: m.index }); const bodyOf = (i) => { const start = marks[i].idx; const end = i + 1 < marks.length ? marks[i + 1].idx : src.length; return { start, body: src.slice(start, end) }; }; // 鉴权可能被抽成辅助方法(如 _requireLogin / _requireOwner), // 调用这些方法与直接调 checkToken 等价 const authHelpers = new Set(); for (let i = 0; i < marks.length; i++) { const { body } = bodyOf(i); if (/checkToken\s*\(/.test(body)) authHelpers.add(marks[i].name); } const helperCallRe = authHelpers.size ? new RegExp(`this\\.(?:${[...authHelpers].join('|')})\\s*\\(`) : null; if (!/checkToken\s*\(/.test(src)) { const hasAnyWrite = /\.(add|update|remove)\s*\(/.test(src); if (hasAnyWrite) { err('云鉴权', `${name} 整个云对象没有鉴权,却存在写库操作`, decl.file, 1); } continue; } for (let i = 0; i < marks.length; i++) { if (!decl.methods.has(marks[i].name)) continue; const { start, body } = bodyOf(i); checked++; const writes = /\.(add|update|remove)\s*\(/.test(body); const auths = /checkToken\s*\(/.test(body) || (helperCallRe && helperCallRe.test(body)); const isGetter = READONLY_HINTS.test(marks[i].name); if (writes && !auths) { err( '云鉴权', `${name}.${marks[i].name}() 有写库操作但未校验登录态`, decl.file, lineAt(src, start) ); } else if (!writes && !auths && !isGetter) { warn( '云鉴权', `${name}.${marks[i].name}() 未校验登录态(读取他人数据风险)`, decl.file, lineAt(src, start) ); } } } stats.cloudAuthChecked = checked; } // ───────────────────────────────────────────── // 0. 自测:确认检查器本身有效(防止"永远通过") // ───────────────────────────────────────────── function selfTest() { const cases = []; const check = (label, actual, expected) => { const ok = actual === expected; cases.push({ label, ok, actual, expected }); }; // 导出解析:四种写法都要认出来 const sample = ` const a = 1; module.exports = { _before: function () {}, async foo(x) { return x }, bar: async () => 1, baz, qux: function () {}, notAMethod: 1, }`; const got = extractExports(sample); check('解析 async 方法', got.has('foo'), true); check('解析箭头函数属性', got.has('bar'), true); check('解析 ES6 简写属性', got.has('baz'), true); check('解析 function 属性', got.has('qux'), true); check('解析 _before 钩子', got.has('_before'), true); check('不把普通值当方法', got.has('notAMethod'), false); // 路由归一化:相对路径要解析成根相对 const routes = new Set(['pages3/uni_modules/x/y', 'pages/index/index']); check( '解析相对路径 ../', resolveRoute('../../pages3/uni_modules/x/y', 'pages2/a/b.vue', routes), 'pages3/uni_modules/x/y' ); check('解析绝对路径 /', resolveRoute('/pages/index/index', 'any/where.vue', routes), 'pages/index/index'); check('剥离查询串', resolveRoute('/pages/index/index?x=1', 'a.vue', routes), 'pages/index/index'); // 下面这些必须被"真实项目扫描"覆盖,否则说明扫描没生效 check('真实扫描到页面路由', stats.routes > 0, true); check('真实扫描到云函数', stats.cloudFunctions > 0, true); check('真实检查了路由跳转', stats.navChecked > 0, true); check('真实检查了云调用', stats.cloudCallsChecked > 0, true); check('真实检查了云函数鉴权', stats.cloudAuthChecked > 0, true); // 云鉴权判定逻辑:写操作无鉴权必须报警,有鉴权则不报 const unsafeFn = ` module.exports = { async dropAll(id) { const db = uniCloud.database() await db.collection('x').doc(id).remove() return { code: 200 } } }`; const safeFn = ` module.exports = { async dropAll(id) { const payload = await this.uniID.checkToken(this.getUniIdToken()) if (payload.errCode) throw new Error('未登录') await db.collection('x').doc(id).remove() return { code: 200 } } }`; const scanAuth = (src) => { const marks = []; let m; WRITE_METHOD_RE.lastIndex = 0; while ((m = WRITE_METHOD_RE.exec(src))) marks.push({ name: m[1], idx: m.index }); let hasUnsafeWrite = false; for (let i = 0; i < marks.length; i++) { const start = marks[i].idx; const end = i + 1 < marks.length ? marks[i + 1].idx : src.length; const body = src.slice(start, end); if (/\.(add|update|remove)\s*\(/.test(body) && !/checkToken\s*\(/.test(body)) { hasUnsafeWrite = true; } } return hasUnsafeWrite; }; check('识别出无鉴权的写方法', scanAuth(unsafeFn), true); check('不误报已鉴权的写方法', scanAuth(safeFn), false); // 鉴权抽成辅助方法时,调用辅助方法也算已鉴权 const helperFn = ` module.exports = { async _requireLogin() { const payload = await this.uniID.checkToken(this.getUniIdToken()) if (payload.errCode) throw new Error('未登录') return payload }, async dropAll(id) { await this._requireLogin() await db.collection('x').doc(id).remove() return { code: 200 } } }`; const detectHelpers = (src2) => { const marks2 = []; let mm; WRITE_METHOD_RE.lastIndex = 0; while ((mm = WRITE_METHOD_RE.exec(src2))) marks2.push({ name: mm[1], idx: mm.index }); const helperSet = new Set(); for (let i = 0; i < marks2.length; i++) { const s = marks2[i].idx; const e = i + 1 < marks2.length ? marks2[i + 1].idx : src2.length; if (/checkToken\s*\(/.test(src2.slice(s, e))) helperSet.add(marks2[i].name); } const re = helperSet.size ? new RegExp(`this\\.(?:${[...helperSet].join('|')})\\s*\\(`) : null; let unsafe = false; for (let i = 0; i < marks2.length; i++) { const s = marks2[i].idx; const e = i + 1 < marks2.length ? marks2[i + 1].idx : src2.length; const b = src2.slice(s, e); const isHelperItself = helperSet.has(marks2[i].name); if (isHelperItself) continue; if (/\.(add|update|remove)\s*\(/.test(b) && !/checkToken\s*\(/.test(b) && !(re && re.test(b))) { unsafe = true; } } return unsafe; }; check('识别经辅助方法鉴权的写操作', detectHelpers(helperFn), false); return cases; } // ───────────────────────────────────────────── // 主流程 // ───────────────────────────────────────────── function main() { const files = walk(ROOT); sectionEncoding(files); const { routes, tabBar } = sectionPages(); sectionNavigation(files, routes, tabBar); const cloudfns = sectionCloudFunctions(); sectionCloudCalls(files, cloudfns); sectionCloudAuth(files, cloudfns); sectionTodos(files); sectionSecrets(files); const selfCases = selfTest(); const selfFailed = selfCases.filter((c) => !c.ok); if (process.argv.includes('--selftest')) { console.log(''); console.log('检查器自测:'); for (const c of selfCases) { console.log(` ${c.ok ? '✓' : '✗'} ${c.label}${c.ok ? '' : `(期望 ${c.expected},实得 ${c.actual})`}`); } console.log(''); console.log(selfFailed.length ? `✗ ${selfFailed.length} 项失败` : '✓ 检查器工作正常'); return selfFailed.length === 0 ? 0 : 1; } if (JSON_OUT) { console.log( JSON.stringify( { ok: errors.length === 0 && selfFailed.length === 0, selfTest: selfCases, errors, warnings, stats }, null, 2 ) ); return errors.length === 0 && selfFailed.length === 0 ? 0 : 1; } const fmt = (list) => list.map((x) => ` ${x.file}${x.line ? ':' + x.line : ''}\n ▸ [${x.section}] ${x.msg}`).join('\n'); console.log(''); console.log('═══════════════════════════════════════════════'); console.log(' 军歌嘹亮 · 静态审计报告'); console.log('═══════════════════════════════════════════════'); console.log(''); console.log(` 页面路由 ${stats.routes} 条 / tabBar ${stats.tabBar} 项`); console.log(` 云函数 ${stats.cloudFunctions} 个`); console.log(` 路由跳转检查 ${stats.navChecked} 处 / 云调用检查 ${stats.cloudCallsChecked} 处`); console.log(` 云函数鉴权检查 ${stats.cloudAuthChecked} 个方法`); console.log(` 编码扫描 ${stats.encodedFiles} 个文件`); console.log( selfFailed.length ? ` ✗ 检查器自测 ${selfFailed.length} 项失败(结果不可信,先跑 --selftest)` : ` ✓ 检查器自测通过(${selfCases.length} 项)` ); console.log(''); if (errors.length) { console.log(`✗ 错误 ${errors.length} 项`); console.log(fmt(errors)); console.log(''); } if (warnings.length) { console.log(`⚠ 警告 ${warnings.length} 项`); console.log(fmt(warnings)); console.log(''); } if (!errors.length && !warnings.length) console.log('✓ 全部检查通过\n'); console.log(`结论: ${errors.length} 错误 / ${warnings.length} 警告`); console.log('═══════════════════════════════════════════════'); return errors.length === 0 ? 0 : 1; } process.exit(main());