【本次核心 · 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 个 —— 历史从未跟踪且属构建产物,保持现状。
295 lines
14 KiB
JavaScript
295 lines
14 KiB
JavaScript
#!/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);
|