805 lines
30 KiB
JavaScript
805 lines
30 KiB
JavaScript
#!/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());
|