#!/usr/bin/env node /** * OCS 5.0 行为模拟器(测试专用,不是产品代码) * ================================================ * 为保证「我们生成的题库配置 / 实际收到的 payload」与 OCS 真实行为一致,本脚本把 * OCS 5.0 源码里的关键纯函数逐段移植到可执行 JS,并用 Node 真实执行(保留 JS 的 * RegExp / Function / Object.values / JSON 往返语义,比 Python 复刻更接近 OCS)。 * * 移植来源(本地解压源码 v5.0): * packages/scripts/src/utils/answerer-env.ts * - IMAGE_URL_REGEX (L190) * - createImageSuggestion (L192-284) * - buildAnswererEnv (L299-342) * - IMAGE_OPTIMIZE_PLACEHOLDERS (L345) * - IMAGE_ENV_REF_PATTERN (L348) * - isAnswererWrapperSupportImageOptimize / isAnswererWrappersSupportImageOptimize (L367-391) * packages/core/src/core/answer-wrapper/answer.wrapper.handler.ts * - env 默认值 (L74-82) * - POST data 字段解析:data 字段 handler 用 Function() 执行 / 字符串走 * resolvePlaceHolder (L123-144, L216-232) * packages/core/src/core/answer-wrapper/answer.wrapper.parser.ts * - fromObject 校验 (L8-50);from(string) 只做 JSON.parse (L52-79) * packages/scripts/src/utils/answer-wrapper-security.ts * - stripCommentsAndStrings / DANGEROUS_PATTERNS / detectHandlerRisks / * scanAnswerWrapperRisks (L20-178) * * 未移植(与本测试无关,测试图片均满足前提): * * imageToBase64 的浏览器/Node 网络分支 —— 测试里通过 imagePayloads 注入转换结果, * 等价于 OCS 在浏览器中成功把图片转成 base64 的结果; * * getImageSize / upscalePngDataUrl 的 <14px 放大分支 —— 测试图片均远大于 14px。 * * 用法(stdin 传 JSON,stdout 返回 JSON): * node ocs5_emulator.mjs < request.json * request.op = "detect" : 检测题库配置(图片题优化支持性 + 可疑代码扫描 + parser 校验) * request.op = "build" : 模拟 buildAnswererEnv + POST data 解析,产出真实 OCS 会发出的请求体 */ const IMAGE_OPTIMIZE_PLACEHOLDERS = ['${images}', '${suggestion_title}', '${suggestion_options}']; const IMAGE_ENV_REF_PATTERN = /(^|[^\w$])(images|suggestion_title|suggestion_options)(?![\w$])/; // 源码 L190:仅匹配带图片扩展名的 URL(DOM 调用方会直接传 titleImages/optionsImages,绕过该正则) const IMAGE_URL_REGEX = /https?:\/\/[^\s\u4e00-\u9fff\uff00-\uffef]+?\.(?:png|jpe?g|gif|bmp|webp|svg)(?:\?[^\s\u4e00-\u9fff\uff00-\uffef]*)?/gi; // ─────────────────────────── answerer-env.ts ─────────────────────────── function isAnswererWrapperSupportImageOptimize(wrapper) { // L367-383 原样移植 if (!wrapper || String(wrapper.method || 'get').toLowerCase() !== 'post') { return false; } for (const value of Object.values(wrapper.data || {})) { if (typeof value === 'string') { if (IMAGE_OPTIMIZE_PLACEHOLDERS.some((placeholder) => value.includes(placeholder))) { return true; } } else if (value && typeof value === 'object' && typeof value.handler === 'string') { if (IMAGE_ENV_REF_PATTERN.test(value.handler)) { return true; } } } return false; } function isAnswererWrappersSupportImageOptimize(wrappers) { return wrappers.some(isAnswererWrapperSupportImageOptimize); } function normalizeToPngDataUrl(dataUrl) { // L178-181 const m = dataUrl.match(/^data:[^;,]+.*?;base64,(.+)$/); return `data:image/png;base64,${m ? m[1] : dataUrl}`; } /** 模拟 imageToBase64:data: 原样返回;否则用注入的转换结果(等价浏览器 canvas 成功转换) */ function imageToBase64(url, imagePayloads) { if (url.startsWith('data:')) { return Promise.resolve(url); } if (imagePayloads && typeof imagePayloads[url] === 'string') { return Promise.resolve(imagePayloads[url]); } // 未注入时生成确定性占位 base64(只用于结构测试,不是合法图片) return Promise.resolve(`data:image/png;base64,${Buffer.from('OCS_EMULATOR:' + url).toString('base64')}`); } /** L192-284 移植(跳过 <14px upscale 分支,见文件头说明) */ async function createImageSuggestion(title, options, titleImages, optionImages, imagePayloads) { const titleUrls = titleImages && titleImages.length ? titleImages.slice() : title.match(IMAGE_URL_REGEX) || []; const optionUrls = optionImages && optionImages.length ? optionImages.slice() : options?.match(IMAGE_URL_REGEX) || []; if (titleUrls.length === 0 && optionUrls.length === 0) return undefined; const allUrls = []; const seen = new Set(); for (const url of [...titleUrls, ...optionUrls]) { if (!seen.has(url)) { seen.add(url); allUrls.push(url); } } const base64Map = new Map(); await Promise.all( allUrls.map(async (url) => { try { base64Map.set(url, await imageToBase64(url, imagePayloads)); } catch { // 转换失败跳过(与源码一致) } }) ); if (base64Map.size === 0) return undefined; const images = []; const urls = []; const placeholderMap = new Map(); for (const url of allUrls) { const base64 = base64Map.get(url); if (!base64) continue; // L241-243:统一重写为 data:image/png;base64,{b64} const normalized = normalizeToPngDataUrl(base64); // L253:' [图片N] ',左右各一个空格(注意源码注释写“两个空格”,实际模板各一个) placeholderMap.set(url, ` [图片${images.length + 1}] `); images.push(normalized); urls.push(url); } if (images.length === 0) return undefined; let suggestionTitle = title; for (const [url, placeholder] of placeholderMap) { suggestionTitle = suggestionTitle.split(url).join(placeholder); } let suggestionOptions; if (options) { const hasOptionImages = optionUrls.some((url) => base64Map.has(url)); if (hasOptionImages) { suggestionOptions = options; for (const [url, placeholder] of placeholderMap) { suggestionOptions = suggestionOptions.split(url).join(placeholder); } } } return { images, urls, suggestion_title: suggestionTitle, ...(suggestionOptions !== undefined ? { suggestion_options: suggestionOptions } : {}) }; } /** L286-297 按行去重 */ function dedupeLines(str) { const seen = new Set(); const result = []; for (const line of str.split('\n')) { if (!seen.has(line)) { seen.add(line); result.push(line); } } return result.join('\n'); } /** L299-342 移植 */ async function buildAnswererEnv(params, imagePayloads) { const rawOptions = Array.isArray(params.options) ? params.options.filter((o) => o != null && o !== '').join('\n') : params.options ?? ''; const options = dedupeLines(rawOptions); const env = { type: params.type || 'unknown', title: params.title, options }; let imageUrls = []; if (params.enableImageOptimize) { const suggestion = await createImageSuggestion( params.title, options, params.titleImages, params.optionsImages, imagePayloads ); if (suggestion) { env.images = suggestion.images; imageUrls = suggestion.urls; env.suggestion_title = suggestion.suggestion_title; if (suggestion.suggestion_options !== undefined) { env.suggestion_options = dedupeLines(suggestion.suggestion_options); } } } return { env, imageUrls }; } // ─────────────────── core: answer.wrapper.handler.ts ─────────────────── /** L226-232 resolvePlaceHolder */ function resolvePlaceHolder(data, env, options) { if (typeof data === 'string') { const matches = data.match(/\${(.*?)}/g) || []; matches.forEach((placeHolder) => { const value = env[placeHolder.replace(/\${(.*)}/, '$1')]; data = data.replace(placeHolder, options?.encodeURI ? encodeURIComponent(value) : value); }); } else if (typeof data === 'object') { const keys = Object.keys(data); for (const key of keys) { data[key] = resolvePlaceHolder(data[key], env, options); } } return data; } /** L43-234 POST 分支移植:返回每个 wrapper 实际会发出的 JSON body(连同 env 默认值) */ function defaultAnswerWrapperHandler(wrappers, env) { const temp = JSON.parse(JSON.stringify(wrappers)); if (temp.length === 0) throw new Error('题库配置不能为空,请配置后重新开始自动答题。'); // L74-82 env 默认值:避免占位符被替换成 'undefined' 字面量 if (env.title === undefined) env.title = ''; if (env.options === undefined) env.options = ''; if (env.type === undefined) env.type = 'unknown'; if (env.images === undefined) env.images = []; if (env.suggestion_title === undefined) env.suggestion_title = ''; if (env.suggestion_options === undefined) env.suggestion_options = ''; const requests = []; for (const wrapper of temp) { const { name = '未知题库', homepage = '#', method = 'get', type = 'fetch', contentType = 'json', responseType = 'json', headers = {}, data: wrapperData = {}, handler = 'return (res)=> [JSON.stringify(res), undefined]' } = wrapper; if (method.toLocaleLowerCase() === 'post') { if (!Object.keys(headers).some((k) => k.toLowerCase() === 'content-type')) { headers['Content-Type'] = 'application/json'; } } let requestData; if (method.toLocaleLowerCase() === 'post') { const data = Object.create({}); Object.keys(wrapperData).forEach((key) => { if (typeof wrapperData[key] === 'object' && Reflect.has(wrapperData[key], 'handler')) { const h = Function(Reflect.get(wrapperData[key], 'handler'))(); if (typeof h !== 'function') throw new Error('data 字段解析器必须返回一个函数'); Reflect.set(data, key, h(env)); } else { Reflect.set(data, key, resolvePlaceHolder(wrapperData[key], env)); } }); requestData = data; } else { // GET:data 会被拼到 query(本配置为 POST,仅作对照) requestData = {}; } // 响应 handler 也执行一次,确保 DSL 本身可被 Function 构造(不校验响应逻辑) const responseHandler = Function(handler)(); if (typeof responseHandler !== 'function') { throw new Error('handler 响应处理器必须返回一个函数'); } const body = JSON.parse(JSON.stringify(requestData)); requests.push({ name, url: wrapper.url, method, contentType, requestType: type, headers, body, bodySize: JSON.stringify(body).length }); } return { envAfterDefaults: env, requests }; } // ────────────── core: answer.wrapper.parser.ts ────────────── function parseFromObject(json) { const aw = json; const errors = []; if (!aw || !Array.isArray(aw)) return { ok: false, errors: ['题库配置格式错误!'] }; if (!aw.length) return { ok: false, errors: ['题库为空!'] }; for (let i = 0; i < aw.length; i++) { const item = aw[i] || {}; if (typeof item.name !== 'string') errors.push(`第 ${i + 1} 个题库的 名字(name) 为空`); if (typeof item.url !== 'string') errors.push(`第 ${i + 1} 个题库的 接口地址(url) 为空`); if (typeof item.handler !== 'string') errors.push(`第 ${i + 1} 个题库的 解析器(handler) 为空`); if (item.headers && typeof item.headers !== 'object') errors.push(`第 ${i + 1} 个题库的 头部信息(header) 应为 对象 格式`); if (item.data && typeof item.data !== 'object') errors.push(`第 ${i + 1} 个题库的 提交数据(data) 应为 对象 格式`); if (['json', 'text'].every((x) => x !== item.contentType) && item.contentType) { errors.push(`第 ${i + 1} 个题库的 contentType 非法`); } if (['post', 'get'].every((x) => x !== item.method) && item.method) { errors.push(`第 ${i + 1} 个题库的 method 非法`); } if (['fetch', 'GM_xmlhttpRequest'].every((x) => x !== item.type) && item.type) { errors.push(`第 ${i + 1} 个题库的 type 非法`); } } return { ok: errors.length === 0, errors }; } // ────────────── scripts: answer-wrapper-security.ts ────────────── const DANGEROUS_PATTERNS = [ { pattern: /\beval\b/, feature: 'eval' }, { pattern: /\bFunction\s*\(/, feature: 'Function()' }, { pattern: /\bfetch\s*\(/, feature: 'fetch' }, { pattern: /\bXMLHttpRequest\b/, feature: 'XMLHttpRequest' }, { pattern: /\bGM_xmlhttpRequest\b|\bGM\s*\.\s*xmlHttpRequest\b/, feature: 'GM_xmlhttpRequest' }, { pattern: /\bGM_\w+|\bGM\s*\.\s*\w+/, feature: 'GM API' }, { pattern: /\blocalStorage\b|\bsessionStorage\b|\bindexedDB\b/, feature: '本地存储' }, { pattern: /\bcookie\b/i, feature: 'cookie' }, { pattern: /\bsendBeacon\b/, feature: 'sendBeacon' }, { pattern: /\bWebSocket\b|\bEventSource\b/, feature: 'WebSocket/EventSource' }, { pattern: /\blocation\b/, feature: 'location' }, { pattern: /\bwindow\b|\bglobalThis\b|\bunsafeWindow\b|\btop\b|\bparent\b|\bopener\b/, feature: '全局对象' }, { pattern: /\bdocument\b/, feature: 'document' }, { pattern: /\bpostMessage\b/, feature: 'postMessage' }, { pattern: /\bimport\s*\(/, feature: 'import()' }, { pattern: /\brequire\s*\(/, feature: 'require()' }, { pattern: /\.constructor\b|\b__proto__\b/, feature: '原型链访问' }, { pattern: /\bfromCharCode\b|\batob\b|\bunescape\b/, feature: '解码函数' }, { pattern: /\bWorker\b/, feature: 'Worker' }, { pattern: /\[\s*['"`][^\]'"]*['"`]\s*\+|\+\s*['"`][^\]'"]*['"`]\s*\]/, feature: '拼接属性访问' } ]; /** L77-138 移植:去掉注释与字符串字面量(避免字符串内关键词误报) */ function stripCommentsAndStrings(code) { const findings = []; let stripped = ''; let i = 0; while (i < code.length) { const ch = code[i]; const next = code[i + 1]; if (ch === '/' && next === '/') { while (i < code.length && code[i] !== '\n') i++; continue; } if (ch === '/' && next === '*') { i += 2; while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++; i += 2; continue; } if (ch === "'" || ch === '"' || ch === '`') { const quote = ch; let str = ''; i++; while (i < code.length && code[i] !== quote) { if (code[i] === '\\') { str += code[i] + (code[i + 1] || ''); i += 2; continue; } if (quote !== '`' && code[i] === '\n') break; str += code[i]; i++; } i++; if (/\\x[0-9a-fA-F]{2}|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\}/.test(str)) { findings.push({ where: '', feature: '转义字符' }); } if (str.length > 100 && /^[A-Za-z0-9+/=\s]+$/.test(str)) { findings.push({ where: '', feature: '超长编码串' }); } if (quote === '`') { for (const match of str.matchAll(/\$\{([^{}]*)\}/g)) { stripped += ' ' + match[1] + ' '; } } stripped += '""'; continue; } stripped += ch; i++; } return { stripped, findings }; } function detectHandlerRisks(code, where = '') { const { stripped, findings } = stripCommentsAndStrings(code); for (const { pattern, feature } of DANGEROUS_PATTERNS) { if (pattern.test(stripped)) findings.push({ where, feature }); } const seen = new Set(); return findings .map((f) => ({ ...f, where })) .filter((f) => { const key = f.where + '|' + f.feature; if (seen.has(key)) return false; seen.add(key); return true; }); } function scanAnswerWrapperRisks(aw) { const findings = []; if (typeof aw.handler === 'string') findings.push(...detectHandlerRisks(aw.handler, 'handler 解析器')); if (aw.data && typeof aw.data === 'object') { for (const key of Object.keys(aw.data)) { const field = aw.data[key]; if (field && typeof field === 'object' && typeof field.handler === 'string') { findings.push(...detectHandlerRisks(field.handler, `data.${key} 字段解析器`)); } } } return findings; } // ────────────────────────────── ops ────────────────────────────── async function opDetect(req) { const wrappers = req.wrappers || []; const perWrapper = wrappers.map((w, i) => ({ index: i, name: w && w.name, method: w && w.method, supportsImageOptimize: isAnswererWrapperSupportImageOptimize(w), risks: scanAnswerWrapperRisks(w || {}), parser: parseFromObject([w]) })); return { supportsImageOptimize: isAnswererWrappersSupportImageOptimize(wrappers), perWrapper, directCases: (req.cases || []).map((c) => ({ name: c.name, result: isAnswererWrapperSupportImageOptimize(c.wrapper), // 单个对象越界字段不影响判断,此处同时输出 Object.values 数量自检 dataValueCount: Object.values((c.wrapper && c.wrapper.data) || {}).length })) }; } async function opBuild(req) { const wrappers = req.wrappers || []; const scenarios = req.scenarios || []; const out = []; for (const scenario of scenarios) { const imagePayloads = scenario.imagePayloads || {}; const { env, imageUrls } = await buildAnswererEnv( { type: scenario.type, title: scenario.title, options: scenario.options, titleImages: scenario.titleImages, optionsImages: scenario.optionsImages, enableImageOptimize: scenario.enableImageOptimize !== false }, imagePayloads ); const { envAfterDefaults, requests } = defaultAnswerWrapperHandler(wrappers, env); out.push({ name: scenario.name, imageUrls, envAfterDefaults, requests }); } return { scenarios: out }; } function opParse(req) { const fromObject = parseFromObject(req.wrappers || []); // from(string) 只做 JSON.parse,不校验字段(parser.ts L69-79) let stringParseOk = true; let stringParseError = null; try { JSON.parse(req.rawString || ''); } catch (e) { stringParseOk = false; stringParseError = String(e && e.message); } return { fromObject, stringParseOk, stringParseError }; } async function main() { let raw = ''; for await (const chunk of process.stdin) raw += chunk; const req = JSON.parse(raw || '{}'); let result; switch (req.op) { case 'detect': result = await opDetect(req); break; case 'build': result = await opBuild(req); break; case 'parse': result = opParse(req); break; default: throw new Error(`unknown op: ${req.op}`); } process.stdout.write(JSON.stringify(result)); } main().catch((e) => { process.stderr.write(String((e && e.stack) || e) + '\n'); process.exit(1); });