519 lines
19 KiB
JavaScript
519 lines
19 KiB
JavaScript
#!/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);
|
||
});
|