// 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11) // 跑完整局,对每一个「服务器 → 某座位」的包做深度扫描,取出其中出现的所有牌 id, // 逐个判定「此刻该座位是否有权知道这张牌」。依据 design §4(暗牌只有庄家可见 / 70分亮3秒)、 // §9(查牌模式)、§11(结束亮底牌)与 server 红线「发全 ≠ 发多,按可见性下发」。 const R = require('./_rpc.js'); const mod = R.mod; const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith; const D = require('../class.desk.js'), EX = require('../class.export.js'); const t = require('./_assert')(); let seed = 0xBEEF01; const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed % n; }; global.min_ontimeout = fn => fn(); global.min_random = (a, b) => a + rnd(b - a + 1); const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) }); // 深度收集一个对象里所有「看起来是牌 id」的整数(0~107)。 // 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。 const CARD_FIELDS = ['cards', 'bottomcards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards']; function collectCards(node, key, out) { if (node === null || node === undefined) return; if (Array.isArray(node)) { node.forEach(x => collectCards(x, key, out)); return; } if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node); return; } if (typeof node === 'object') { for (const k in node) collectCards(node[k], CARD_FIELDS.indexOf(k) >= 0 ? k : key, out); } } const leaks = []; let pkts = 0, scanned = 0; let ctxRef = null; // 判定:seat 此刻是否有权知道 cid function mayKnow(pj, seat, cid, rpc) { const c = pj.cards[cid]; if (c.dealowner === -1) return true; // 规则去除的 3/4,不可能出现 if (c.playround > 0) return true; // 已打出,全场可见 if (c.dealowner === seat + 1) return true; // 自己的牌 if (seat === pj.banker && (c.dealowner === 0 || c.dealowner === seat + 1)) return true; // 庄家可见暗牌/底牌 if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0) return true; // §4 70分亮3秒 if (rpc === 'jiesuan' && c.playround === 0) return true; // §11 结束亮底牌 if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验 return false; } function run(roomtype, call) { const sent = []; const o_room = { roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T', seatlist: [0, 1, 2].map(i => ({ conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P', avatar: '', gameinfo: {} })), method: { sendpack_toother: m => sent.push({ to: 'ALL', m: JSON.parse(JSON.stringify(m)) }) } }; const desk = D.new(o_room); o_room.o_desk = desk; global.youle_erqiwang.app = { SendPack: m => sent.push({ to: m.fromid, m: JSON.parse(JSON.stringify(m)) }) }; global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } }; mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app; D.do_new_paiju(desk, 0); const pj = desk.method.curr_paiju(); ctxRef = { pj, sent }; const audit = () => { while (sent.length) { const { to, m } = sent.shift(); pkts++; const targets = to === 'ALL' ? [0, 1, 2] : [to]; const out = new Set(); collectCards(m.data, null, out); for (const seat of targets) for (const cid of out) { scanned++; if (!mayKnow(pj, seat, cid, m.rpc)) { leaks.push(`rpc=${m.rpc} → seat${seat} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); } } } }; audit(); mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call })); audit(); if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); } if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); } const b = pj.banker; mod.xuanzhu(pk(b, { flower: 1 + rnd(4) })); audit(); mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) })); audit(); // 重连快照也要审计(每个座位各取一次) for (let s = 0; s < 3; s++) { const di = EX.new().get_deskinfo(o_room, s); const out = new Set(); collectCards(di, null, out); pkts++; for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step${pj.step}) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); } } let guard = 0; while (pj.step === 5 && ++guard < 400) { const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat); let pick = null; if (seat === pj.playproc.start) pick = [hand[hand.length - 1]]; else for (const c of hand) if (A.can_followcard(pj.flower, hand, [c], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [c]; break; } if (!pick) break; mod.chupai(pk(seat, { cards: pick })); audit(); if (guard % 7 === 0) { // 中途穿插重连与明牌 for (let s = 0; s < 3; s++) { const di = EX.new().get_deskinfo(o_room, s); const out = new Set(); collectCards(di, null, out); pkts++; for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step5) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); } } if (pj.method.have_baofu()) { sent.length = 0; mod.mingpai(pk(0)); // §9 明牌:只允许返回「另外两家未出的主牌」,多一张都算越权 for (const { m } of sent) { if (m.rpc !== 'mingpai' || !m.data.others) continue; for (const o of m.data.others) { for (const cid of o.zhucards) { scanned++; const c = pj.cards[cid]; const isTrumpCard = c.flower === pj.flower || c.flower === 5 || c.number === 2 || c.number === 7; if (c.dealowner !== o.seat + 1 && !(o.seat === pj.banker && c.dealowner === 0)) leaks.push(`mingpai 返回了非该座位的牌 ${cid}`); if (c.playround !== -1) leaks.push(`mingpai 返回了已出/已埋的牌 ${cid}`); if (!isTrumpCard) leaks.push(`mingpai 返回了非主牌 ${cid}`); } } } sent.length = 0; } } } audit(); return pj; } for (const rt of ['00000', '00001']) for (const call of [65, 70, 5]) run(rt, call); t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []); // 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量 t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true); t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true); console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定`); process.exit(t.done('leak') ? 0 : 1);