Files
t/tools/audit-project.js
T
root 3117146281 feat: 打通 HBuilderX CLI 构建 + 修复文章闭环静态盲区
构建:
- 新增 tools/build.js:junction HBuilderX 工具链,CLI 构建 h5/mp-weixin
- vue 指向补丁版 @dcloudio/uni-h5-vue(官方 npm vue 不导出 isInSSRComponentSetup)
- 设 HX_APP_ROOT 避免退化成 H5 空壳产物;产物完整性校验

校验工具:
- 新增 check-cloud-methods.js:acorn 解析云对象方法,比对 94 处调用点
- 新增 check-android-contract.js:Kotlin 侧云对象契约校验
- audit-project.js 修 downloadFile 误报(注释未剥离);tools/ 排除出扫描
- package.json 声明此前隐式依赖的 acorn

功能:
- 补 uni-cms-articles.getPublishedArticles(安卓端依赖但此前不存在)
- 修 u-parse <audio> 引用已移除组件导致 H5 构建失败
2026-09-12 01:02:45 +08:00

802 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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']);
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()) {
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());