@codecot/pw-checker
Version:
A comprehensive CLI tool to audit passwords locally using HIBP and import from Chrome/Bitwarden
342 lines ⢠10.3 kB
JavaScript
// riskScoring.ts - Comprehensive risk scoring system for password entries
import sqlite3 from "sqlite3";
import { open } from "sqlite";
import chalk from "chalk";
import { dbPath } from "./database.js";
export const DEFAULT_RISK_WEIGHTS = {
compromisedPassword: 50, // Major red flag
emailBreachesPerBreach: 10, // 10 points per breach (max 50)
criticalAccountCategory: 30, // Banking, work, etc.
oldPasswordAge: 15, // More than 12 months old
weakPassword: 20, // Short, simple passwords
duplicatePassword: 15, // Same password used elsewhere
noTwoFactorAuth: 10, // No 2FA indication
};
// Critical account categories (case-insensitive matching)
const CRITICAL_CATEGORIES = [
// Banking & Finance
"bank",
"banking",
"financial",
"finance",
"paypal",
"venmo",
"cash",
"investment",
"trading",
"crypto",
"bitcoin",
"wallet",
"credit",
"loan",
"mortgage",
// Work & Professional
"work",
"office",
"company",
"corporate",
"enterprise",
"business",
"professional",
"gmail",
"outlook",
"email",
"microsoft",
"google",
"aws",
"azure",
"office365",
// Government & Legal
"government",
"gov",
"irs",
"tax",
"legal",
"court",
"dmv",
"social security",
// Healthcare
"health",
"medical",
"hospital",
"doctor",
"pharmacy",
"insurance",
// Critical Infrastructure
"utility",
"electric",
"gas",
"water",
"internet",
"phone",
"mobile",
];
/**
* Determine risk level based on score
*/
export function getRiskLevel(score) {
if (score >= 80) {
return {
score,
label: "Critical",
color: "#8B0000", // Dark red
description: "Immediate action required - change this password now!",
};
}
else if (score >= 60) {
return {
score,
label: "High",
color: "#FF0000", // Red
description: "High risk - change this password soon",
};
}
else if (score >= 30) {
return {
score,
label: "Medium",
color: "#FFA500", // Orange
description: "Moderate risk - consider updating this password",
};
}
else {
return {
score,
label: "Low",
color: "#008000", // Green
description: "Low risk - password appears secure",
};
}
}
/**
* Check if account is in a critical category
*/
export function isCriticalAccount(name, url) {
const searchText = `${name} ${url}`.toLowerCase();
return CRITICAL_CATEGORIES.some((category) => searchText.includes(category));
}
/**
* Calculate password strength score (0-100, lower is weaker)
*/
export function calculatePasswordStrength(password) {
if (!password)
return 100; // No password = very weak, max penalty
let weaknessScore = 0;
// Length penalty
if (password.length < 8)
weaknessScore += 30;
else if (password.length < 12)
weaknessScore += 15;
// Character variety
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigits = /[0-9]/.test(password);
const hasSpecial = /[^a-zA-Z0-9]/.test(password);
const charTypes = [hasLower, hasUpper, hasDigits, hasSpecial].filter(Boolean).length;
if (charTypes < 2)
weaknessScore += 25;
else if (charTypes < 3)
weaknessScore += 15;
else if (charTypes < 4)
weaknessScore += 5;
// Common patterns (case insensitive)
const lowerPassword = password.toLowerCase();
const commonPatterns = [
"password",
"123456",
"qwerty",
"abc123",
"admin",
"letmein",
"welcome",
"monkey",
"111111",
"dragon",
"master",
"princess",
];
if (commonPatterns.some((pattern) => lowerPassword.includes(pattern))) {
weaknessScore += 40;
}
// Sequential patterns
if (/123|abc|qwe|asd|zxc/i.test(password)) {
weaknessScore += 20;
}
// Repeated characters
if (/(.)\1{2,}/.test(password)) {
weaknessScore += 15;
}
return Math.min(100, weaknessScore);
}
/**
* Check if password is older than specified months
*/
export function isPasswordOld(lastCheckedAt, monthsThreshold = 12) {
if (!lastCheckedAt)
return true; // Unknown age = assume old
const lastCheck = new Date(lastCheckedAt);
const monthsAgo = new Date();
monthsAgo.setMonth(monthsAgo.getMonth() - monthsThreshold);
return lastCheck < monthsAgo;
}
/**
* Calculate comprehensive risk score for a password entry
*/
export function calculateRiskScore(entry, allEntries, weights = DEFAULT_RISK_WEIGHTS) {
let score = 0;
const factors = [];
// 1. Compromised password (highest priority)
if (entry.compromised === true) {
score += weights.compromisedPassword;
factors.push("Password found in data breaches");
}
// 2. Email found in breaches
if (entry.breach_info) {
try {
const breachInfo = JSON.parse(entry.breach_info);
if (breachInfo.breached && breachInfo.count) {
const breachScore = Math.min(breachInfo.count * weights.emailBreachesPerBreach, 50);
score += breachScore;
factors.push(`Email found in ${breachInfo.count} data breach${breachInfo.count > 1 ? "es" : ""}`);
}
}
catch (e) {
// Invalid JSON, skip
}
}
// 3. Critical account category
if (isCriticalAccount(entry.name, entry.url)) {
score += weights.criticalAccountCategory;
factors.push("Critical account category (banking, work, etc.)");
}
// 4. Password age
if (isPasswordOld(entry.last_checked_at)) {
score += weights.oldPasswordAge;
factors.push("Password not updated in over 12 months");
}
// 5. Weak password
const passwordWeakness = calculatePasswordStrength(entry.password);
if (passwordWeakness > 0) {
const weaknessScore = Math.round((passwordWeakness / 100) * weights.weakPassword);
score += weaknessScore;
if (passwordWeakness > 50) {
factors.push("Very weak password");
}
else if (passwordWeakness > 25) {
factors.push("Weak password");
}
}
// 6. Duplicate password
const duplicateCount = allEntries.filter((other) => other.id !== entry.id &&
other.password === entry.password &&
entry.password).length;
if (duplicateCount > 0) {
score += weights.duplicatePassword;
factors.push(`Password reused across ${duplicateCount + 1} accounts`);
}
return { score: Math.min(100, Math.round(score)), factors };
}
/**
* Update risk scores for all entries in the database
*/
export async function updateAllRiskScores() {
const db = await open({
filename: dbPath,
driver: sqlite3.Database,
});
try {
// Add risk columns if they don't exist
await db
.exec(`
ALTER TABLE pw_entries ADD COLUMN risk_score INTEGER DEFAULT NULL;
`)
.catch(() => { }); // Ignore if column already exists
await db
.exec(`
ALTER TABLE pw_entries ADD COLUMN risk_label TEXT DEFAULT NULL;
`)
.catch(() => { }); // Ignore if column already exists
await db
.exec(`
ALTER TABLE pw_entries ADD COLUMN risk_factors TEXT DEFAULT NULL;
`)
.catch(() => { }); // Ignore if column already exists
// Get all entries
const entries = await db.all(`
SELECT id, name, url, username, password, compromised, source,
last_checked_at, notes, breach_info
FROM pw_entries
`);
console.log(chalk.blue(`š§® Calculating risk scores for ${entries.length} entries...`));
// Calculate risk scores for all entries
for (const entry of entries) {
const { score, factors } = calculateRiskScore(entry, entries);
const riskLevel = getRiskLevel(score);
await db.run(`
UPDATE pw_entries
SET risk_score = ?, risk_label = ?, risk_factors = ?
WHERE id = ?
`, [score, riskLevel.label, JSON.stringify(factors), entry.id]);
}
console.log(chalk.green("ā
Risk scores updated successfully!"));
// Show summary statistics
const riskStats = await db.all(`
SELECT risk_label, COUNT(*) as count
FROM pw_entries
WHERE risk_label IS NOT NULL
GROUP BY risk_label
ORDER BY
CASE risk_label
WHEN 'Critical' THEN 1
WHEN 'High' THEN 2
WHEN 'Medium' THEN 3
WHEN 'Low' THEN 4
END
`);
console.log(chalk.blue.bold("\nš Risk Assessment Summary:"));
for (const stat of riskStats) {
const color = stat.risk_label === "Critical"
? chalk.red
: stat.risk_label === "High"
? chalk.red
: stat.risk_label === "Medium"
? chalk.yellow
: chalk.green;
console.log(`${color(`${stat.risk_label}:`)} ${stat.count} accounts`);
}
}
catch (error) {
console.error(chalk.red("ā Error updating risk scores:"), error);
throw error;
}
finally {
await db.close();
}
}
/**
* Get entries sorted by risk score
*/
export async function getEntriesByRisk(limit) {
const db = await open({
filename: dbPath,
driver: sqlite3.Database,
});
try {
let query = `
SELECT id, name, url, username, password, compromised, source,
last_checked_at, notes, breach_info, risk_score, risk_label, risk_factors
FROM pw_entries
WHERE risk_score IS NOT NULL
ORDER BY risk_score DESC, name ASC
`;
if (limit) {
query += ` LIMIT ${limit}`;
}
const entries = await db.all(query);
return entries;
}
finally {
await db.close();
}
}
//# sourceMappingURL=riskScoring.js.map