Files
competition-website/scripts/scan_weak_password_accounts.cjs
qubiaobiao a2e24aba2a feat(auth): 口令强度策略(弱口令黑名单 / 账号派生值 / 长度上限)
背景(R61 实测):浏览器密码管理器在「设置密码」页预填 123456,考生若直接提交,该口令即成为其账号真实口令;而后端此前仅由 validate 校验「长度 >= 6」,弱口令完全合法。R62 已从前端修掉预填,本次补齐后端这道闸门。

- app/lib/passwordPolicy.js + password_blacklist.json:口令策略唯一事实来源。黑名单刻意做成纯 JSON,供 Node 运行时与运维脚本共用,避免两份清单漂移。

- controller/auth.js:activate() 与 resetPassword() 接入策略(错误码 2001 / 7001),位置在 validate 之后、短信校验之前(纯本地计算、无副作用、快速失败)。

- validate/index.js:补 max:20,与前端 el-form 规则对齐(此前只有前端限制长度上限)。

- scripts/_db.cjs:DB 配置解析唯一入口;scan_weak_password_accounts.cjs:存量弱口令只读扫描器;verify_password_policy_api.cjs:真接口验证(含「强口令通过」正向对照臂,防假绿)。
2026-09-30 11:56:35 +08:00

275 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<string|null>}
*/
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 <pwd> 追加自定义候选口令(可重复)
--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);
});