Files
competition-website/scripts/scan_weak_password_accounts.cjs

275 lines
11 KiB
JavaScript
Raw Permalink Normal View History

#!/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);
});