はじめに
パスワードの「使い回し」と、Neko2023! → Neko2024! のような「末尾だけ変えたパスワード」を見つける点検ツールを作りました。ブラウザやパスワード管理ツールから書き出したCSVを読み込むと、問題のあるものを一覧にします。
https://tools.equaglobal.com/PassAudit.html (体験版。サンプルデータで動きます)
パスワードを扱うツールなので、データを外に一切送らないことを最優先にして、ブラウザの JavaScript だけで作りました。この記事では、その中心になっている判定のしくみを、Node.js でテストできる形に切り出して紹介します。
- CSV を自前で読む
- 使い回しは「パスワードごとにサイトをまとめる」
- 似ているパスワードは「前後の数字・記号を落とした芯」で比べる
- 画面には最初と最後の1文字しか出さない
※この記事とツールは、AI(Claude)を活用して作成し、コードはテストで動作を確認しています。
1. CSV を自前で読む
パスワードの CSV には、メモ欄などにカンマや改行が入ることがあります。split(",") では壊れるので、ダブルクォートを見ながら1文字ずつ読みます。ライブラリを読み込まないので、外部への通信も増えません。
// CSV を行の配列にする(ダブルクォート・改行入りの値に対応)
function parseCSV(text) {
const rows = [];
let row = [], f = "", q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) {
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; }
else f += c;
} else if (c === '"') q = true;
else if (c === ",") { row.push(f); f = ""; }
else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(f); rows.push(row); row = []; f = "";
} else f += c;
}
if (f !== "" || row.length) { row.push(f); rows.push(row); }
return rows.filter(r => r.some(x => x !== ""));
}
列名はブラウザやパスワード管理ツールによって違います。この記事のコードでは url username password という列名を前提にしていますが、ツール本体では列名の候補をいくつかの正規表現で探しています。
2. 使い回しは「パスワードごとにサイトをまとめる」
パスワードをキーにした Map にまとめ、2つ以上の違うサイトにあるものを「使い回し」とします。同じサイトに複数のアカウントがある場合は使い回しに数えないよう、サイトはホスト名の Set で数えます。
// URL からホスト名を取り出す(www. は外す)
const hostOf = u => {
try { return new URL(/^[a-z]+:\/\//i.test(u) ? u : "https://" + u).hostname.replace(/^www\./, ""); }
catch (e) { return String(u || ""); }
};
// 1) 使い回し:同じパスワードが 2 つ以上のサイトにある
const byPw = new Map();
for (const e of E) {
if (!byPw.has(e.pw)) byPw.set(e.pw, []);
byPw.get(e.pw).push(e);
}
const reused = [...byPw.values()].filter(l => new Set(l.map(e => e.host)).size > 1);
3. 似ているパスワードは「芯」で比べる
よくあるのが、使い回しを避けたつもりで末尾の数字や記号だけ変えるパターンです。
-
Neko2023!とNeko2024! -
sakura01とsakura02
前後の数字・記号・アンダースコアを取り除き、小文字にした「芯」が同じなら「似ている」とします。芯が短いと無関係なものまで引っかかるので、4文字以上のときだけ比べます。
// 前後の数字・記号を落とした「芯」の部分
const baseOf = p => p.toLowerCase().replace(/[\d\W_]+$/, "").replace(/^[\d\W_]+/, "");
// 2) 似ている:芯が 4 文字以上で同じ、パスワード自体は違う
const byBase = new Map();
for (const pw of byPw.keys()) {
const b = baseOf(pw);
if (b.length < 4) continue;
if (!byBase.has(b)) byBase.set(b, new Set());
byBase.get(b).add(pw);
}
const similar = [...byBase.values()].filter(s => s.size > 1).map(s => [...s]);
この方法は単純ですが、Neko!2023 のように真ん中に数字が入る形は見つけられません。編集距離(レーベンシュタイン距離)なども考えましたが、誤検出が増えるわりに説明が難しくなるため、「なぜ似ていると判定したか」を利用者が一目で分かるこの方法にしました。
4. 画面には最初と最後の1文字しか出さない
点検結果の表にパスワードをそのまま出すと、画面を共有したときや印刷したときに漏れます。表示は最初と最後の1文字だけにして、あいだは伏せ字にします。
// 表示用:最初と最後の 1 文字だけ見せる
const mask = p => p.length <= 2 ? "••" : p[0] + "•".repeat(Math.min(10, p.length - 2)) + p[p.length - 1];
ツール本体では、修正リストを CSV で保存する機能でも、パスワードの列は書き出しません。
全体のコード(audit.js)
ここまでの部品をまとめたものです。
// CSV を行の配列にする(ダブルクォート・改行入りの値に対応)
function parseCSV(text) {
const rows = [];
let row = [], f = "", q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) {
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; }
else f += c;
} else if (c === '"') q = true;
else if (c === ",") { row.push(f); f = ""; }
else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(f); rows.push(row); row = []; f = "";
} else f += c;
}
if (f !== "" || row.length) { row.push(f); rows.push(row); }
return rows.filter(r => r.some(x => x !== ""));
}
// URL からホスト名を取り出す(www. は外す)
const hostOf = u => {
try { return new URL(/^[a-z]+:\/\//i.test(u) ? u : "https://" + u).hostname.replace(/^www\./, ""); }
catch (e) { return String(u || ""); }
};
// 前後の数字・記号を落とした「芯」の部分
const baseOf = p => p.toLowerCase().replace(/[\d\W_]+$/, "").replace(/^[\d\W_]+/, "");
function audit(csvText) {
const rows = parseCSV(csvText.replace(/^\uFEFF/, ""));
const head = rows[0].map(h => h.trim().toLowerCase());
const col = re => head.findIndex(h => re.test(h));
const pc = col(/^password$/), uc = col(/^username$/), urc = col(/^url$/);
const E = rows.slice(1)
.filter(r => r[pc])
.map(r => ({ host: hostOf(r[urc]), user: r[uc] || "", pw: r[pc] }));
// 1) 使い回し:同じパスワードが 2 つ以上のサイトにある
const byPw = new Map();
for (const e of E) {
if (!byPw.has(e.pw)) byPw.set(e.pw, []);
byPw.get(e.pw).push(e);
}
const reused = [...byPw.values()].filter(l => new Set(l.map(e => e.host)).size > 1);
// 2) 似ている:芯が 4 文字以上で同じ、パスワード自体は違う
const byBase = new Map();
for (const pw of byPw.keys()) {
const b = baseOf(pw);
if (b.length < 4) continue;
if (!byBase.has(b)) byBase.set(b, new Set());
byBase.get(b).add(pw);
}
const similar = [...byBase.values()].filter(s => s.size > 1).map(s => [...s]);
return { count: E.length, reused, similar };
}
// 表示用:最初と最後の 1 文字だけ見せる
const mask = p => p.length <= 2 ? "••" : p[0] + "•".repeat(Math.min(10, p.length - 2)) + p[p.length - 1];
module.exports = { parseCSV, baseOf, audit, mask };
テスト
Node.js の assert だけでテストしています。カンマ入りのメモ、www. の有無、同じパスワードの2サイト、末尾違いの2つを入れたデータです。
const assert = require("assert");
const { parseCSV, baseOf, audit, mask } = require("./audit.js");
const csv = 'name,url,username,password,note\r\n' +
'A,https://www.example.com/login,taro,Neko2023!,\r\n' +
'B,https://shop.example.net/,taro@example.com,Neko2023!,\r\n' +
'C,https://mail.example.org/,taro,Neko2024!,"memo, with comma"\r\n' +
'D,https://bank.example.jp/,taro,x7#Lq-92mVp!Rz,\r\n';
const r = audit(csv);
assert.strictEqual(r.count, 4);
assert.strictEqual(r.reused.length, 1);
assert.deepStrictEqual(r.reused[0].map(e => e.host), ["example.com", "shop.example.net"]);
assert.deepStrictEqual(r.similar, [["Neko2023!", "Neko2024!"]]);
assert.strictEqual(baseOf("Neko2024!"), "neko");
assert.strictEqual(mask("Neko2023!"), "N•••••••!");
assert.deepStrictEqual(parseCSV('a,"b ""q"", c"\n1,2')[0], ["a", 'b "q", c']);
console.log("all ok", JSON.stringify(r.similar));
$ node test.js
all ok [["Neko2023!","Neko2024!"]]
送信しないことの確かめ方
「送信しない」と書くだけでは信じてもらいにくいので、次のようにしています。
- 外部の JavaScript ライブラリや CDN を読み込まない(判定はすべてこのページの JavaScript)
-
fetchやXMLHttpRequestを使うコードを書かない - ファイルは
File.text()で読み、メモリの中だけで処理する - さらに、ページの Content-Security-Policy で通信そのものを止める
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob: https://*.a8.net; connect-src 'none'">
connect-src 'none' にしておくと、万一コードに fetch が紛れ込んでも、ブラウザが通信を拒否します。(体験版のページには広告の画像があるため、img-src だけ広告の配信元を許可しています。)
ブラウザの開発者ツールの「ネットワーク」タブを開いたまま CSV を読み込むと、CSV の中身がどこにも送られていないことを利用者自身でも確認できます。
使うときの注意
書き出した CSV には、すべてのパスワードが平文で入っています。点検はパソコンの中だけで行い、メールやクラウドに置かず、終わったらすぐに削除して、ゴミ箱も空にしてください。
なお、このツールは漏えいしたパスワードのデータベースとは照合しません。漏えいの確認は、Chrome の「パスワード チェックアップ」など、ブラウザやパスワード管理ツールの機能を使ってください。
まとめ
- CSV はクォートを見ながら自前で読む(メモ欄のカンマ・改行対策)
- 使い回しは「パスワード → サイトの集合」で、違うサイトが2つ以上
- 似ているパスワードは、前後の数字・記号を落とした4文字以上の芯で比べる
- 表示は伏せ字にして、データは外に送らない