#!/usr/bin/env node 'use strict'; /** * 弱口令存量账号扫描器(R63 · C2) * ============================================================ * * 用途 * ------------------------------------------------------------ * R61 测试人员实测:进入「设置密码」页时浏览器密码管理器**预填 `123456`**, * 若考生**直接提交**,该口令就成为其账号的真实口令。R62 已从前端修掉预填 * (激活页清残留登录态 + 密码框声明 autocomplete 语义),但**存量**账号里 * 可能已经存在这种口令。本脚本负责**精确识别**它们。 * * ⛔ 本脚本是**只读**的 * ------------------------------------------------------------ * 全程只执行 `SELECT`。**不做任何 UPDATE / DELETE / INSERT**,也不删文件。 * 识别结果仅用于人工决策(是否重设口令、如何通知考生)。 * —— 口令哈希不可逆,唯一可靠的识别方式就是"拿候选口令逐条 bcrypt 比对"。 * * 为什么用 Node 而不是 Python * ------------------------------------------------------------ * 必须与后端**同一个 bcrypt 实现**(`backend/node_modules/bcrypt`,原生模块)比对, * 避免跨语言实现差异带来的假阴性;且本机 Python 无 bcrypt 依赖(服务器端有 * `/opt/pytools/lib`,但"同一实现"比"能跑"更重要)。黑名单直接从后端策略的 * JSON 读取,**不复制一份**(复制必然漂移)。 * * 判据设计(避免把"没扫到"误读成"没问题") * ------------------------------------------------------------ * · 命中即短路:每行按候选顺序比对,一旦命中就停止(`123456` 排在最前, * 它是本次风险的核心)。 * · 未激活账号默认跳过:其口令来自导入时的派生值(生产 = 身份证后 6 位 SHA256, * 见 `app/service/admin.js:384`),**不是**考生自己设置的,扫描无意义。 * 如需连未激活一起看,加 `--all`。 * · 输出末尾打印**扫描覆盖面**(扫了多少行 / 比对多少次 / 跳过多少行), * 让"零命中"成为一个有依据的结论,而不是空输出。 * * 用法 * ------------------------------------------------------------ * # 本地库(凭据取自 scripts/.creds.local.json,或环境变量) * node backend/scripts/scan_weak_password_accounts.cjs * * # 追加自定义候选口令 * node backend/scripts/scan_weak_password_accounts.cjs --extra MyPwd123 * * # 连未激活账号一起扫 * node backend/scripts/scan_weak_password_accounts.cjs --all * * # 机器可读输出 * node backend/scripts/scan_weak_password_accounts.cjs --json * * 退出码 * 0 = 扫描完成(无论是否命中;命中数在输出中提示) * 2 = 参数错误 / 连接失败 */ const crypto = require('crypto'); const mysql = require('mysql2/promise'); const bcrypt = require('bcrypt'); /** 黑名单 —— 与后端策略共用同一份 JSON(单一事实来源,勿另存副本) */ const BLACKLIST = require('../app/lib/password_blacklist.json'); /** bcrypt 哈希识别(与 extend/helper.js 的 isBcryptHash 同口径) */ function isBcryptHash(h) { return typeof h === 'string' && /^\$2[aby]\$/.test(h); } /** 存量 SHA256(无盐)哈希 */ function sha256(s) { return crypto.createHash('sha256').update(s).digest('hex'); } /** * DB 配置解析 —— 与其它运维脚本共用同一入口。 * ⚠️ 不在此处另写一份:优先级与「缺口令即明确失败」的纪律必须全脚本一致。 */ const { loadDbConfig } = require('./_db.cjs'); /** 身份证号脱敏:保留前 6 后 4 */ function maskIdNumber(v) { const s = String(v || ''); if (s.length <= 10) return s ? s[0] + '***' : ''; return `${s.slice(0, 6)}****${s.slice(-4)}`; } /** 命令行参数解析(极简,无需依赖) */ function parseArgs(argv) { const opts = { extra: [], all: false, json: false, quiet: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--all') opts.all = true; else if (a === '--json') opts.json = true; else if (a === '--quiet') opts.quiet = true; else if (a === '--extra') opts.extra.push(argv[++i]); else if (a === '--help' || a === '-h') opts.help = true; else { console.error(`未知参数:${a}(用 --help 查看用法)`); process.exit(2); } } return opts; } /** * 逐行比对:命中即返回命中的候选口令,未命中返回 null。 * * @param {object} row - 含 password 的账号行 * @param {string[]} candidates - 候选口令(已去重、已排序) * @returns {Promise} */ async function findWeakPassword(row, candidates) { const hash = row.password; if (!hash) return null; const bcryptHash = isBcryptHash(hash); for (const candidate of candidates) { if (bcryptHash) { // 慢路径(每条约 0.1-0.3s,取决于 cost) // eslint-disable-next-line no-await-in-loop if (await bcrypt.compare(candidate, hash)) return candidate; } else { // 存量 SHA256:瞬时 if (sha256(candidate) === hash) return candidate; } } return null; } async function main() { const opts = parseArgs(process.argv.slice(2)); if (opts.help) { console.log(` 弱口令存量账号扫描器(只读) 用法: node backend/scripts/scan_weak_password_accounts.cjs [选项] 选项: --extra 追加自定义候选口令(可重复) --all 连未激活账号一并扫描(默认只扫 activated=1) --json 以 JSON 输出结果 --quiet 只输出汇总 --help 显示本帮助 `); return 0; } // 候选口令:123456 优先(本次风险核心),其余按黑名单顺序;去重 const seen = new Set(); const candidates = []; for (const p of ['123456', ...BLACKLIST, ...opts.extra]) { const s = String(p); if (!seen.has(s)) { seen.add(s); candidates.push(s); } } const cfg = loadDbConfig(); const conn = await mysql.createConnection({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, database: cfg.database, }); try { // ── 只读扫描 ──────────────────────────────────────── // 列名以 model 定义为准(contestants):id / name / id_number / phone / group / // activated / test_flag / password。⛔ 不可跨表套用其它表的列名。 const sql = [ 'SELECT id, name, id_number, phone, `group`, activated, test_flag, password', 'FROM contestants', opts.all ? '' : 'WHERE activated = 1', 'ORDER BY id', ].filter(Boolean).join(' '); const [rows] = await conn.query(sql); const total = rows.length; const skipped = rows.filter(r => !r.password).length; let comparisons = 0; const hits = []; let bcryptRows = 0; for (const row of rows) { if (!row.password) continue; if (isBcryptHash(row.password)) bcryptRows++; comparisons += candidates.length; // eslint-disable-next-line no-await-in-loop const matched = await findWeakPassword(row, candidates); if (matched) { hits.push({ id: row.id, name: row.name, id_number: maskIdNumber(row.id_number), group: row.group, activated: row.activated, test_flag: row.test_flag, matched_password: matched, hash_type: isBcryptHash(row.password) ? 'bcrypt' : 'sha256', }); } } // ── 输出 ──────────────────────────────────────────── if (opts.json) { console.log(JSON.stringify({ scanned: total, skipped_no_password: skipped, bcrypt_rows: bcryptRows, candidates: candidates.length, comparisons, hits_count: hits.length, hits, }, null, 2)); } else { console.log(''); console.log('=== 弱口令存量账号扫描(只读)==='); console.log(`数据库 : ${cfg.user}@${cfg.host}:${cfg.port}/${cfg.database}`); console.log(`扫描范围 : ${opts.all ? '全部账号' : '仅 activated=1(已激活)'}`); console.log(`账号行数 : ${total}(其中无口令 ${skipped} 行已跳过;bcrypt 哈希 ${bcryptRows} 行)`); console.log(`候选口令 : ${candidates.length} 条 × ${total - skipped} 行 = ${comparisons} 次比对`); console.log(''); if (hits.length === 0) { console.log('✓ 零命中:没有任何账号的口令命中候选弱口令清单'); console.log(' (本结论的覆盖面见上方「账号行数 / 候选口令」两行,非空输出)'); } else { console.log(`⚠️ 命中 ${hits.length} 个账号(口令为弱口令):`); console.log(''); console.log(' id 组别 测试? 已激活 姓名 身份证(脱敏) 口令 哈希'); console.log(' ------ -------------- ------ ------ ---------------- -------------------- ----------- --------'); for (const h of hits) { console.log( ' ' + String(h.id).padEnd(7) + String(h.group || '').padEnd(15) + (h.test_flag ? '是 ' : '否 ') + (h.activated ? '是 ' : '否 ') + String(h.name || '').padEnd(17) + String(h.id_number || '').padEnd(21) + String(h.matched_password).padEnd(12) + h.hash_type ); } console.log(''); console.log(' 判读提示(重要,勿误判):'); console.log(' · test_flag=1 且口令=123456:通常是**设计内的测试便利口令**'); console.log(' (config.default.js:153 testDefaultPassword;由 resetTestData / 测试数据导入直写库,'); console.log(' 不经口令策略),**不是**被浏览器预填污染的考生账号。'); console.log(' · 真正的风险信号 = **test_flag=0(正式考生)且口令命中弱口令清单**。'); console.log(''); console.log(' ⇒ 处置建议:重设其口令为随机强口令,并提示考生走「忘记密码」自行设置。'); console.log(' ⚠️ 不要直接把口令告知考生本人以外的人;本表含 PII,勿外发。'); } console.log(''); } return 0; } finally { await conn.end(); } } main() .then(code => process.exit(code)) .catch(e => { console.error('✗ 扫描失败:' + e.message); process.exit(2); });