update
This commit is contained in:
320
scripts/check-third-pass-audit.mjs
Normal file
320
scripts/check-third-pass-audit.mjs
Normal file
@@ -0,0 +1,320 @@
|
||||
// 第三轮全面审计回归:覆盖第二、三轮的所有修复点。
|
||||
//
|
||||
// 涵盖:
|
||||
// 安全 S1 - dataDir 允许根目录白名单(home / documents / downloads / desktop / userData / appData)
|
||||
// 安全 S2 - dataDir realpath 解析后再次校验(防止 symlink 绕过白名单)
|
||||
// 安全 S3 - loadConfig 过滤 __proto__/constructor/prototype 防止原型污染
|
||||
// 安全 S4 - 路径不合法 / 不存在时抛错而非 console.warn 静默吞掉
|
||||
// 错误处理 E1 - app.js bootstrap 有 .bak 自动回退
|
||||
// 错误处理 E2 - task-list addTask 返回 null 时弹 toast
|
||||
// 错误处理 E3 - task-store loadFromContent parseMarkdown 抛错时 emit load-failed
|
||||
// 数据完整性 D1 - markdown-writer 写入 originalCategory 转义 ]
|
||||
// 数据完整性 D2 - markdown-parser 读回 originalCategory 反转义
|
||||
// 数据完整性 D3 - 多行任务文本 \\n 保留
|
||||
// 性能 P1 - task-list SVG 字面量提升(STAR_SVG_FILLED 等)
|
||||
// 性能 P2 - task-list 拖拽 rAF 节流(pendingMove + rafScheduled)
|
||||
// 性能 P3 - task-list _flushPendingDrag 在 pointerup 同步落点
|
||||
// 性能 P4 - app.js setupStatusBar 文本相同则跳过 DOM 写入
|
||||
// 并发 C1 - feedback.js 模块级监听器 AbortController + disposeFeedbackGlobals
|
||||
// 并发 C2 - app.js export disposeBootstrap() 可取消所有 bootstrap 监听器
|
||||
// 卫生 H1 - app.js 没有未限定的 console.log(保留 console.error / console.warn)
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { check, ok, bad, printSummary } from './_lib/check.mjs';
|
||||
|
||||
const MAIN = readFileSync('main.js', 'utf8');
|
||||
const APP = readFileSync('src/app.js', 'utf8');
|
||||
const TASK_LIST = readFileSync('src/ui/task-list.js', 'utf8');
|
||||
const TASK_STORE = readFileSync('src/task-store.js', 'utf8');
|
||||
const FEEDBACK = readFileSync('src/ui/feedback.js', 'utf8');
|
||||
const MARKDOWN_WRITER = readFileSync('src/markdown-writer.js', 'utf8');
|
||||
const MARKDOWN_PARSER = readFileSync('src/markdown-parser.js', 'utf8');
|
||||
|
||||
function section(name) { console.log(`\n[${name}]`); }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 安全 S1+S2: dataDir 允许根目录白名单 + realpath 防 symlink 绕过
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('安全: dataDir 白名单 + symlink 防御');
|
||||
|
||||
check(
|
||||
'main.js dataDir 校验使用 app.getPath("home") 等白名单',
|
||||
/app\.getPath\(['"]home['"]\)/.test(MAIN),
|
||||
'白名单是 home / documents / downloads / desktop / userData / appData'
|
||||
);
|
||||
check(
|
||||
'main.js dataDir 校验使用 app.getPath("documents")',
|
||||
/app\.getPath\(['"]documents['"]\)/.test(MAIN)
|
||||
);
|
||||
check(
|
||||
'main.js dataDir 校验使用 app.getPath("downloads")',
|
||||
/app\.getPath\(['"]downloads['"]\)/.test(MAIN)
|
||||
);
|
||||
check(
|
||||
'main.js dataDir 校验使用 app.getPath("desktop")',
|
||||
/app\.getPath\(['"]desktop['"]\)/.test(MAIN)
|
||||
);
|
||||
check(
|
||||
'main.js dataDir 校验使用 app.getPath("userData")',
|
||||
/app\.getPath\(['"]userData['"]\)/.test(MAIN)
|
||||
);
|
||||
check(
|
||||
'main.js dataDir 校验使用 app.getPath("appData")',
|
||||
/app\.getPath\(['"]appData['"]\)/.test(MAIN)
|
||||
);
|
||||
check(
|
||||
'main.js 用 realpathSync 解析后再比对白名单',
|
||||
/realpathSync/.test(MAIN) && /\.toLowerCase\(\)/.test(MAIN),
|
||||
'symlink 解析后再做白名单比对,防止间接绕过'
|
||||
);
|
||||
check(
|
||||
'main.js dataDir 校验失败时抛 Error 而非 console.warn',
|
||||
/throw new Error\([`"]数据目录必须在用户已知目录内/.test(MAIN) ||
|
||||
/throw new Error\([`"]数据目录/.test(MAIN),
|
||||
'v4+ 修复:抛错让 IPC reject,UI 走 save-error toast'
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 安全 S3: loadConfig 过滤 __proto__ 等危险 key
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('安全: 原型污染过滤');
|
||||
|
||||
check(
|
||||
'main.js 有 _stripProtoKeys helper',
|
||||
/_stripProtoKeys/.test(MAIN),
|
||||
'递归过滤 __proto__ / constructor / prototype 三个 key'
|
||||
);
|
||||
check(
|
||||
'_stripProtoKeys 拒绝 __proto__',
|
||||
/['"]__proto__['"]/.test(MAIN) || /__proto__/.test(MAIN)
|
||||
);
|
||||
check(
|
||||
'_stripProtoKeys 拒绝 constructor / prototype',
|
||||
/constructor/.test(MAIN) && /prototype/.test(MAIN)
|
||||
);
|
||||
check(
|
||||
'loadConfig 走 _stripProtoKeys 后再 spread',
|
||||
/loadConfig[\s\S]{0,300}_stripProtoKeys/.test(MAIN)
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 错误处理 E1: .bak 自动回退
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('错误处理: .bak 自动回退');
|
||||
|
||||
check(
|
||||
'app.js 有 tryReadBak helper',
|
||||
/tryReadBak/.test(APP),
|
||||
'loadFromContent 解析失败 / 空内容时尝试 .bak'
|
||||
);
|
||||
check(
|
||||
'app.js bootstrap 在 store.loadFromContent 失败时尝试 .bak',
|
||||
/tryReadBak[\s\S]{0,500}store\.loadFromContent|store\.loadFromContent[\s\S]{0,200}tryReadBak/.test(APP) ||
|
||||
/\.bak/.test(APP),
|
||||
'解析失败 → 读 .bak → 再 loadFromContent'
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 错误处理 E2: addTask 返回 null 时弹 toast
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('错误处理: addTask null 反馈');
|
||||
|
||||
check(
|
||||
'task-list.js 在 store.addTask 返回 null 时弹 toast',
|
||||
/store\.addTask\([\s\S]{0,200}\bif\s*\(\s*task\s*\)[\s\S]{0,400}\belse\s*\{[\s\S]{0,200}toast/.test(TASK_LIST) ||
|
||||
/store\.addTask[\s\S]{0,300}\bif\s*\(\s*!\s*task/.test(TASK_LIST),
|
||||
'添加失败要给用户反馈,不能静默吞掉'
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 错误处理 E3: parseMarkdown 抛错时 emit load-failed
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('错误处理: parseMarkdown 异常捕获');
|
||||
|
||||
check(
|
||||
'task-store.js loadFromContent 对 parseMarkdown 加 try/catch',
|
||||
/parseMarkdown\(content\)[\s\S]{0,300}catch\s*\(\s*parseErr\s*\)/.test(TASK_STORE) ||
|
||||
/try\s*\{[\s\S]{0,50}incomingCategories\s*=\s*parseMarkdown/.test(TASK_STORE),
|
||||
'解析失败要 emit load-failed 让 UI 走 .bak / 冲突解决路径'
|
||||
);
|
||||
check(
|
||||
'task-store.js parseMarkdown 抛错时 emit("load-failed", ...)',
|
||||
/emit\(['"]load-failed['"]/.test(TASK_STORE)
|
||||
);
|
||||
check(
|
||||
'parseMarkdown 抛错时 Error.code === "PARSE_FAILED"',
|
||||
/code\s*=\s*['"]PARSE_FAILED['"]/.test(TASK_STORE)
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 数据完整性 D1+D2: originalCategory 转义 / 反转义
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('数据完整性: originalCategory 转义');
|
||||
|
||||
check(
|
||||
'markdown-writer 对 originalCategory 转义 ](避开解析字段截断)',
|
||||
MARKDOWN_WRITER.includes('/\\]/g'),
|
||||
'写盘时 ] 必须转义为 \\],否则 parser 在 [原分类:工作] 这类含 ] 名字里截断'
|
||||
);
|
||||
check(
|
||||
'markdown-parser 解析 originalCategory 的正则允许 \\.',
|
||||
/\\\\\.|\\\\\\./.test(MARKDOWN_PARSER) ||
|
||||
/\\\\\\./.test(MARKDOWN_PARSER),
|
||||
'parser 必须接受 \\. 让转义能往返'
|
||||
);
|
||||
check(
|
||||
'markdown-parser 解析后 unescapeOriginalCategory 把 \\] / \\\\ 还原',
|
||||
/unescapeOriginalCategory/.test(MARKDOWN_PARSER) ||
|
||||
MARKDOWN_PARSER.includes("replace(/\\\\\\\\\\\\/g"),
|
||||
'读回时反转义:\\] → ],\\\\ → \\'
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 数据完整性 D3: 多行任务文本 \\n 保留
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('数据完整性: 多行任务文本');
|
||||
|
||||
check(
|
||||
'markdown-writer 写盘时换行转义为 \\n',
|
||||
/escapeTaskTextSegment/.test(MARKDOWN_WRITER) ||
|
||||
MARKDOWN_WRITER.includes("split('\\\\n')") ||
|
||||
MARKDOWN_WRITER.includes('\\\\r\\\\n'),
|
||||
'内存里 \\n 实际是换行符,写盘要写成 \\n 否则 parser 当成多任务处理'
|
||||
);
|
||||
check(
|
||||
'markdown-parser 读回时把 \\n 还原为换行',
|
||||
MARKDOWN_PARSER.includes('/\\\\n/g') ||
|
||||
MARKDOWN_PARSER.includes('replace(/\\\\n/'),
|
||||
'parser 把 \\n 序列还原成换行符,保证文本往返一致'
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 性能 P1: SVG 字面量提升
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('性能: SVG 字面量提升');
|
||||
|
||||
check(
|
||||
'task-list.js 有 STAR_SVG_FILLED 等模块级 SVG 常量',
|
||||
/STAR_SVG_FILLED/.test(TASK_LIST) && /STAR_SVG_OUTLINE/.test(TASK_LIST),
|
||||
'原本是每次 _buildItem 重新构造,提到模块层只生成一次'
|
||||
);
|
||||
check(
|
||||
'task-list.js 有 starSvgFor(active) helper',
|
||||
/starSvgFor/.test(TASK_LIST)
|
||||
);
|
||||
check(
|
||||
'task-list.js 有 currentSvgFor(active) helper',
|
||||
/currentSvgFor/.test(TASK_LIST)
|
||||
);
|
||||
check(
|
||||
'task-list.js 没有重复的 inline STAR_SVG 模板字面量',
|
||||
(TASK_LIST.match(/<svg[^>]*xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g) || []).length <= 8,
|
||||
'多个 svg 都从常量引用,不应该重复模板字面量'
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 性能 P2+P3: 拖拽 rAF 节流
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('性能: 拖拽 rAF 节流');
|
||||
|
||||
check(
|
||||
'task-list.js pointermove 缓存 pendingMove',
|
||||
/d\.pendingMove\s*=\s*e/.test(TASK_LIST),
|
||||
'把最近一次 move 存到 d.pendingMove,下一帧才真正 _updateDrag'
|
||||
);
|
||||
check(
|
||||
'task-list.js pointermove 用 rafScheduled 标志',
|
||||
/d\.rafScheduled\s*=\s*true/.test(TASK_LIST) &&
|
||||
/requestAnimationFrame/.test(TASK_LIST)
|
||||
);
|
||||
check(
|
||||
'task-list.js 有 _flushPendingDrag 在 pointerup 同步落点',
|
||||
/_flushPendingDrag/.test(TASK_LIST),
|
||||
'避免松手瞬间落点还停留在上一帧动画结束位置(≤8ms 偏差)'
|
||||
);
|
||||
check(
|
||||
'_finishDrag 在移动前先 _flushPendingDrag',
|
||||
/_finishDrag\([\s\S]{0,200}_flushPendingDrag/.test(TASK_LIST) ||
|
||||
/_flushPendingDrag\(d\)/.test(TASK_LIST)
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 性能 P4: 状态栏缓存
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('性能: 状态栏文本相同跳过 DOM 写入');
|
||||
|
||||
check(
|
||||
'app.js setupStatusBar 有 oldText 缓存',
|
||||
/oldText/.test(APP) || /nextText\s*===\s*oldText/.test(APP)
|
||||
);
|
||||
check(
|
||||
'app.js setupStatusBar 比较 nextText 与 oldText 后才写入',
|
||||
/nextText\s*===\s*oldText/.test(APP) || /text\s*===\s*oldText/.test(APP)
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 并发 C1: feedback.js 模块级监听器 AbortController
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('并发: feedback.js 模块级监听器 AbortController');
|
||||
|
||||
check(
|
||||
'feedback.js 有 globalListenerAC',
|
||||
/globalListenerAC/.test(FEEDBACK)
|
||||
);
|
||||
check(
|
||||
'feedback.js 有 globalListenersInstalled 标志',
|
||||
/globalListenersInstalled/.test(FEEDBACK)
|
||||
);
|
||||
check(
|
||||
'feedback.js 有 installGlobalListeners() 一次性安装',
|
||||
/installGlobalListeners/.test(FEEDBACK)
|
||||
);
|
||||
check(
|
||||
'feedback.js export disposeFeedbackGlobals',
|
||||
/export\s+function\s+disposeFeedbackGlobals/.test(FEEDBACK),
|
||||
'提供显式 dispose 入口给 hot-reload / 测试用'
|
||||
);
|
||||
check(
|
||||
'feedback.js 模块级监听器走 signal 而非裸 addEventListener',
|
||||
/\{ signal:\s*globalListenerAC\.signal\s*\}/.test(FEEDBACK),
|
||||
'document.addEventListener(..., {signal}) 让 abort 时一键清理'
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 并发 C2: bootstrapUnsubs disposeBootstrap
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('并发: disposeBootstrap 清理函数');
|
||||
|
||||
check(
|
||||
'app.js export disposeBootstrap',
|
||||
/export\s+function\s+disposeBootstrap/.test(APP)
|
||||
);
|
||||
check(
|
||||
'disposeBootstrap 取消 bootstrapUnsubs 池中的所有函数',
|
||||
/disposeBootstrap[\s\S]{0,400}bootstrapUnsubs\.pop/.test(APP) ||
|
||||
/bootstrapUnsubs[\s\S]{0,400}disposeBootstrap/.test(APP)
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 卫生 H1: app.js 没有未限定的 console.log(保留 console.error/warn)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
section('卫生: 剥离调试日志');
|
||||
|
||||
// 检查 app.js 顶层 / bootstrap() 里没有 console.log 残留
|
||||
// 允许的例外:rendererLog(...) 这种被 isDev / syncLog 守卫的 helper 内部。
|
||||
// 把所有 .test(/console\.log/) 之外、非 helper 函数体内部的 console.log 视为违规。
|
||||
const helperBodies = APP.match(/function\s+\w+\s*\([^)]*\)\s*\{[\s\S]*?console\.log[\s\S]*?\n\}/g) || [];
|
||||
const helperLines = new Set();
|
||||
for (const body of helperBodies) {
|
||||
for (const line of body.split('\n')) helperLines.add(line.trim());
|
||||
}
|
||||
const allLogs = APP.split('\n').filter(l => /console\.log/.test(l));
|
||||
const strayLogs = allLogs.filter(l => !helperLines.has(l.trim()));
|
||||
check(
|
||||
'app.js 顶层 / bootstrap 里没有 console.log 残留(仅允许 dev-守卫的 helper 内)',
|
||||
strayLogs.length === 0,
|
||||
`残留位置:${strayLogs.map(l => l.trim()).slice(0, 3).join(' | ')}`
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
printSummary('check-third-pass-audit');
|
||||
Reference in New Issue
Block a user