Files
aurora-admin/tools/verify-emits-parse.mjs
aurora-admin eb25feedaf feat(S6-P21): 组件族参数化(13族/44成员/导航族15文件同源/79→48概念组件)
【本次核心 · S6-P21】
- 族层数据:families.json + 44 份契约注入 family/familyRole/familyParams;
  data.json / data.js / site/details 同步。13 族 / 44 成员 / 35 独立 → 概念组件 79→48。
- 79 个 slug 全保留、集合逐一不变(铁律 5 对外承诺未破);frameworks 仍 395 文件、薄壳仍 79。
- 归族判据为契约中可核对字段(semanticTypeCandidates 重叠 / anatomy 为同一骨架子集 /
  变体维度同构 / doNotInvent 显式从属声明),每族 mergeBasis 写明依据,不按名字猜。
- 实现层合并(导航族端到端切片):tools/gen-family-impl.mjs 从 5 端模板生成
  TopMenu / SideMenu / MixedNavigation 共 15 文件,参数 direction=top|side|mixed;
  三份 CSS md5 完全相同 = 一份样式表服务三个组件。
- 新增 tools/gen-families.mjs、tools/gen-family-impl.mjs、tools/verify-families.mjs、
  tools/lib/family-model.mjs、tools/lib/family-impl/nav-menu/*.tpl。

【同时清掉此前已完成但未提交的批次】
生成物(data.json / data.js / site/sources / site/components 薄壳 / sitemap.xml / tests 报告)
跨阶段交织,无法拆成互相自洽的多个提交,故按既有批量风格合并提交:
- Package:三端可 import(S5-P18)+ 发布到私有 npm 源
- Docs site:导航语言改下拉(S5-P19)、详情页代码块默认展开、中英切换完整性
- Security:生产部署链审计修复(2026-09-19)+ 线上部署
- Theme modes 日间/夜间/自动;S1-P4 data.js 瘦身;S2-P5 暗色;S2-P6 跨端一致性;
  S2-P7 行为断言;S2-P9 FAQ;S3-P8 RTL;S3-P9 契约缺口解释层;S4-P12 发布流程
- 补入 tools/pack-deploy.mjs、run-site-smoke.mjs、verify-*.mjs,.dockerignore、
  安全审计修复与待决策项.md

【验收】
- node tools/verify-families.mjs → OK: 族层端到端一致(13 族 / 44 成员 / 79 组件不变 / 395 文件不变)
- node tools/verify-cross-platform.mjs → 79/79 identical(HEAD 基线 high 44)
- node tools/run-regression.mjs → 100%(79/79 页,1017/1017 断言,N/A 34),连跑 8 次一致,0 超时
- 逐页实测:topmenu / sidemenu / mixednavigation 各 13/13,帧内 direction 参数正确,0 JS 错误
- 零运行时依赖 OK;build-site.ps1 ASCII-only OK

【未纳入】site/components/<slug>/ 平台薄壳 316 个 —— 历史从未跟踪且属构建产物,保持现状。
2026-09-20 03:32:31 +08:00

295 lines
14 KiB
JavaScript
Raw Permalink 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
/**
* verify-emits-parse.mjs — defineEmits 解析的安全性与行为等价性验证(零依赖)。
*
* 背景(实测缺陷 P2):
* 修复前 site/app.js 用
* var emitArr = (new Function('return ' + mEmits[1]))();
* 求值 defineEmits 的数组字面量。任何能改 frameworks/*.vue* 的人(内部威胁 /
* 投毒 PR)只要写 defineEmits([1,globalThis.__pwned = 'x']),打开该组件详情页
* 就会在文档站同源下执行任意 JS。实测:
* MATCH "[1,globalThis.__pwned = \"executed-arbitrary-js\"]" -> 已实际执行
*
* 本脚本做三件事:
* 1) 静态断言 site/app.js 里不再存在动态字符串求值点([COUNTEREXAMPLE],修复前必失败);
* 2) 从 site/app.js 中抽出修复后的解析函数(parseStringLiteralArray / isHexRun),
* 与「修复前的求值实现」在全部 frameworks/*.vue(约 158 个)上逐一比对抽取结果,
* 断言不一致数为 0 —— 证明替换没有改变真实行为;
* 3) 断言恶意字面量在新解析下既不执行、也不抛错;同时用修复前实现做阳性对照,
* 证明这组恶意输入「真的可执行」,即反例断言有区分力。
*
* 说明:所有「动态求值」都发生在 node:vm 的独立沙箱 context 内(刻意保留的修复前对照
* 实现,等价于修复前的 return + 函数构造器),被测文件 site/app.js 自身不含任何求值点。
*
* 运行:node tools/verify-emits-parse.mjs
* 退出码:0 = 全部通过;1 = 有断言失败。
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createContext, runInContext } from 'node:vm';
const ROOT = join(import.meta.dirname, '..');
const APP_PATH = join(ROOT, 'site', 'app.js');
const FW_DIR = join(ROOT, 'frameworks');
const app = readFileSync(APP_PATH, 'utf8');
let failed = 0;
let passed = 0;
const results = [];
function check(name, ok, detail = '') {
results.push({ name, ok, detail });
if (ok) passed++;
else failed++;
}
/* ---------- 1. 静态断言 ---------- */
const evalSink = app.match(/\bnew\s+Function\b|\beval\s*\(/);
check('[COUNTEREXAMPLE] site/app.js 无动态字符串求值点(修复前第 1229 行是 new Function(...))',
evalSink === null, evalSink ? `found ${JSON.stringify(evalSink[0])} @ offset ${evalSink.index}` : 'clean');
check('defineEmits 的匹配正则保持原样',
app.includes(String.raw`/defineEmits\s*\(\s*(\[[^\]]+\])\s*\)/`));
check('$emit( 的扫描正则保持原样',
app.includes(String.raw`/\$emit\(\s*['"]([a-zA-Z0-9_:-]+)['"]/g`));
check('新的安全解析被真实调用',
app.includes('var emitArr = mEmits ? parseStringLiteralArray(mEmits[1]) : null;'));
/* ---------- 2. 从 site/app.js 抽出解析函数(被测代码 = 实际部署的文本) ---------- */
function skipString(src, i) {
const quote = src[i];
i++;
while (i < src.length) {
if (src[i] === '\\') { i += 2; continue; }
if (src[i] === quote) return i + 1;
i++;
}
return i;
}
function extractFunction(src, startMarker) {
const start = src.indexOf(startMarker);
if (start < 0) return null;
let depth = 0, seen = false, i = start;
while (i < src.length) {
const ch = src[i];
if (ch === '/' && src[i + 1] === '/') { const nl = src.indexOf('\n', i); if (nl < 0) return null; i = nl; continue; }
if (ch === '/' && src[i + 1] === '*') { const c = src.indexOf('*/', i + 2); if (c < 0) return null; i = c + 2; continue; }
if (ch === "'" || ch === '"' || ch === '`') { i = skipString(src, i); continue; }
if (ch === '{') { depth++; seen = true; i++; continue; }
if (ch === '}') { depth--; if (seen && depth === 0) return src.slice(start, i + 1); i++; continue; }
i++;
}
return null;
}
const helpers = [
extractFunction(app, 'function isHexRun(s, n) {'),
extractFunction(app, 'function parseStringLiteralArray(literal) {')
];
const region = helpers.every(Boolean) ? helpers.join('\n') : null;
check('从 site/app.js 抽出解析函数源码', region !== null,
region ? `bytes=${region.length}(isHexRun + parseStringLiteralArray)` : '未找到解析函数');
if (!region) {
for (const r of results) console.log(`[emits] ${r.ok ? 'OK' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
console.error('\n[emits] 无法继续:解析函数抽取失败');
process.exit(1);
}
check('抽取结果含 parseStringLiteralArray 定义', /function parseStringLiteralArray\(/.test(region));
const sandbox = createContext({});
runInContext(region, sandbox, { filename: 'site/app.js#emits-parser' });
const parseStringLiteralArray = sandbox.parseStringLiteralArray;
check('解析函数可在沙箱中独立加载(无外部依赖)', typeof parseStringLiteralArray === 'function');
/* ---------- 3. 与修复前实现逐一比对 ---------- */
const DEFINE_EMITS_RE = /defineEmits\s*\(\s*(\[[^\]]+\])\s*\)/;
const DEFINE_EMITS_RE_G = /defineEmits\s*\(\s*(\[[^\]]+\])\s*\)/g;
/* 修复前的实现:把字面量当作代码求值(在沙箱内执行,等价于 return + 函数构造器)。
这是刻意保留的对照实现,只用于证明反例可执行、以及与真实文件的行为一致性。 */
function legacyParse(literal, ctx) {
return runInContext(literal, ctx);
}
/* 修复后的实现:调用被测文件里抽出的解析函数 */
function safeParse(literal) {
const arr = parseStringLiteralArray(literal);
return Array.isArray(arr) ? arr : null;
}
/* 复刻 site/app.js「2. 提炼 Emits」分支的贡献(desc / 去重逻辑两版一致,
故唯一变量就是解析方式;$emit( 扫描与 JSX 回退不在此分支内,未改动) */
function defineEmitsNames(code, parser, ctx) {
const m = code.match(DEFINE_EMITS_RE);
if (!m) return [];
let arr;
try {
arr = parser(m[1], ctx);
} catch {
return []; // 修复前实现 catch (err) {} 的等价行为
}
if (!Array.isArray(arr)) return [];
const names = [];
try {
arr.forEach((e) => {
const desc = e.indexOf('update:') === 0 ? 'model' : 'event'; // 与 app.js 同构
if (!names.some((x) => x.name === e)) names.push({ name: e, desc });
});
} catch {
/* 修复前 forEach 中途抛错时保留已 push 的部分 */
}
return names;
}
const vueFiles = readdirSync(FW_DIR).filter((f) => f.endsWith('.vue')).sort();
let withLiteral = 0;
let occurrences = 0;
let entries = 0;
let mismatches = 0;
const mismatchDetail = [];
const sweepCtx = createContext({});
for (const file of vueFiles) {
const code = readFileSync(join(FW_DIR, file), 'utf8');
occurrences += (code.match(DEFINE_EMITS_RE_G) || []).length;
const oldNames = defineEmitsNames(code, legacyParse, sweepCtx);
const newNames = defineEmitsNames(code, safeParse, sweepCtx);
if (oldNames.length || newNames.length) {
withLiteral++;
entries += newNames.length;
}
if (JSON.stringify(oldNames) !== JSON.stringify(newNames)) {
mismatches++;
if (mismatchDetail.length < 5) {
mismatchDetail.push(`${file}: old=${JSON.stringify(oldNames)} new=${JSON.stringify(newNames)}`);
}
}
}
check(`frameworks/*.vue 全量比对(${vueFiles.length} 文件)不一致数为 0`, mismatches === 0,
`含 defineEmits 数组字面量的文件 ${withLiteral} / 字面量 ${occurrences} 处 / 事件名 ${entries} 条 / 不一致 ${mismatches}`);
check('扫描到的 Vue 实现文件数量与预期一致(158)', vueFiles.length === 158, `count=${vueFiles.length}`);
if (mismatchDetail.length) mismatchDetail.forEach((d) => console.log(`[emits] mismatch ${d}`));
/* ---------- 4. 恶意输入:不执行 / 不抛错(含修复前阳性对照) ---------- */
const HOSTILE = [
['defineEmits([1,globalThis.__pwned = "executed-arbitrary-js"])', '数字元素 + 赋值表达式'],
['defineEmits([globalThis.__pwned = 1])', '纯赋值表达式元素'],
["defineEmits(['a' + (globalThis.__pwned = 1)])", '字符串拼接注入'],
['defineEmits([(globalThis.__pwned = 1)])', '括号包裹表达式'],
["defineEmits(['a', () => { globalThis.__pwned = 1 }])", '箭头函数元素'],
['defineEmits([`${globalThis.__pwned = 1}`])', '模板字符串元素']
];
const controlCtx = createContext({});
let controlExecuted = 0;
for (const [src, label] of HOSTILE) {
const m = src.match(DEFINE_EMITS_RE);
if (!m) continue;
try { legacyParse(m[1], controlCtx); } catch { /* 旧实现内部 catch */ }
if (runInContext('globalThis.__pwned', controlCtx) !== undefined) {
controlExecuted++;
runInContext('delete globalThis.__pwned', controlCtx);
}
}
check('阳性对照:修复前实现确实会执行这些恶意输入(证明反例有区分力)',
controlExecuted > 0, `${controlExecuted}/${HOSTILE.length} 个恶意输入在旧实现下实际执行`);
const hostileCtx = createContext({});
runInContext(region, hostileCtx, { filename: 'site/app.js#emits-parser' });
const safeHostile = hostileCtx.parseStringLiteralArray;
let executed = 0;
let threw = 0;
const hostileOutcome = [];
for (const [src, label] of HOSTILE) {
const m = src.match(DEFINE_EMITS_RE);
if (!m) { hostileOutcome.push(`${label}: 正则未匹配(不进入解析)`); continue; }
let out;
try {
out = safeHostile(m[1]);
} catch (e) {
threw++;
hostileOutcome.push(`${label}: THREW ${e.message}`);
continue;
}
if (out !== null) hostileOutcome.push(`${label}: 返回了非 null -> ${JSON.stringify(out)}`);
else hostileOutcome.push(`${label}: null(拒绝解析)`);
}
if (runInContext('typeof globalThis.__pwned', hostileCtx) !== 'undefined') executed++;
check('新实现不执行任何恶意输入(globalThis.__pwned 保持 undefined)', executed === 0);
check('新实现对恶意输入不抛错(全部返回 null)', threw === 0, hostileOutcome.join(' | '));
/* ---------- 5. 真实写法必须仍能抽取 ---------- */
/* 入参是 defineEmits(...) 里捕获到的**完整数组字面量**(含方括号),与 app.js 的调用一致 */
const positive = [
["['update:modelValue','change']", ['update:modelValue', 'change'], '真实文件的单引号写法'],
['["click","change"]', ['click', 'change'], '双引号写法'],
["[ 'a' , 'b' ]", ['a', 'b'], '多余空白'],
["['a',]", ['a'], '尾逗号'],
["[ 'don\\'t' ]", ["don't"], '转义单引号按字面量还原'],
['["a\\u0062c"]', ['abc'], '\\u 转义还原'],
['["a\\x62"]', ['ab'], '\\x 转义还原'],
['[]', [], '空数组字面量(app.js 的正则不取,解析器本身为空数组)'],
["['a', 1]", null, '混入非字符串 -> 整体拒绝'],
["['a' + 'b']", null, '表达式 -> 整体拒绝'],
['[`t`]', null, '模板字符串 -> 整体拒绝'],
["['a', ...b]", null, '展开 -> 整体拒绝'],
["[ 'a\\qb' ]", null, '字符串内未识别转义 -> 整体拒绝']
];
let positiveFail = 0;
const positiveSeen = [];
for (const [literal, expected, label] of positive) {
const got = safeParse(literal);
const ok = JSON.stringify(got) === JSON.stringify(expected);
if (!ok) positiveFail++;
positiveSeen.push(`${label}=${ok ? 'OK' : `FAIL(${JSON.stringify(got)})`}`);
}
check('单引号 / 双引号 / 空白 / 尾逗号 / 转义的真实写法仍正确抽取', positiveFail === 0, positiveSeen.join(' '));
/* ---------- 5.5 构建期同源副本交叉校验(存在才校验;缺失只提示,不算通过) ---------- */
const LIB_PATH = join(ROOT, 'tools', 'lib', 'parse-string-literal-array.mjs');
if (existsSync(LIB_PATH)) {
const libMod = await import(pathToFileURL(LIB_PATH).href);
const libParse = libMod.parseStringLiteralArray;
check('构建期副本 tools/lib/parse-string-literal-array.mjs 导出解析函数', typeof libParse === 'function');
let libMismatch = 0;
const libDetail = [];
for (const file of vueFiles) {
const m = readFileSync(join(FW_DIR, file), 'utf8').match(DEFINE_EMITS_RE);
if (!m) continue;
let libOut;
try { const r = libParse(m[1]); libOut = Array.isArray(r) ? r : null; } catch (e) { libOut = `THREW ${e.message}`; }
if (JSON.stringify(safeParse(m[1])) !== JSON.stringify(libOut)) {
libMismatch++;
if (libDetail.length < 3) libDetail.push(`${file}: app.js=${JSON.stringify(safeParse(m[1]))} lib=${JSON.stringify(libOut)}`);
}
}
check('构建期副本与 site/app.js 在 158 个 .vue 上抽取结果一致(构建/运行不分叉)',
libMismatch === 0, `不一致 ${libMismatch}${libDetail.length ? ' :: ' + libDetail.join(' | ') : ''}`);
let libExecuted = 0;
for (const [src] of HOSTILE) {
const m = src.match(DEFINE_EMITS_RE);
if (!m) continue;
try { libParse(m[1]); } catch { /* 抛错由下面的断言覆盖 */ }
if (globalThis.__pwned !== undefined) { libExecuted++; delete globalThis.__pwned; }
}
check('构建期副本同样不执行恶意输入', libExecuted === 0);
} else {
console.log('[emits] INFO 未发现 tools/lib/parse-string-literal-array.mjs(构建期同源副本),跳过交叉校验');
}
/* ---------- 6. 真实文件抽样:抽到的事件名必须与源文件字面量一一对应 ---------- */
const sampleFile = vueFiles.find((f) => DEFINE_EMITS_RE.test(readFileSync(join(FW_DIR, f), 'utf8')));
const sampleCode = readFileSync(join(FW_DIR, sampleFile), 'utf8');
const sampleLiteral = sampleCode.match(DEFINE_EMITS_RE)[1];
const sampleOut = defineEmitsNames(sampleCode, safeParse, sweepCtx);
check(`抽样 ${sampleFile} 抽到的事件名与源字面量一致`,
sampleOut.length > 0 && sampleOut.every((x) => sampleLiteral.includes(`'${x.name}'`) || sampleLiteral.includes(`"${x.name}"`)),
`literal=${sampleLiteral} -> ${JSON.stringify(sampleOut.map((x) => x.name))}`);
/* ---------- 汇总 ---------- */
for (const r of results) {
console.log(`[emits] ${r.ok ? 'OK' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
}
if (failed) {
console.error(`\n[emits] ${failed} check(s) failed / ${passed} passed`);
process.exit(1);
}
console.log(`\n[emits] OK — ${passed} checks passed;${vueFiles.length} 个 .vue 文件双实现比对不一致 0`);
process.exit(0);