dev1/backend/shared/db/withEncryption.js
Josh 888bdd2939
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Runtime hardening, logs, rate limits
2025-08-28 18:03:45 +00:00

164 lines
6.2 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.

/* ────────────────────────────────────────────────────────────────
Dropin replacement for mysql2 that transparently encrypts /
decrypts selected columns, using helpers above.
---------------------------------------------------------------- */
import mysql from 'mysql2/promise';
import {
encrypt,
decrypt,
isEncrypted,
initEncryption
} from '../crypto/encryption.js';
const WRITE_RE = /^\s*(insert|update|replace)\s/i;
/* ── map of columns that must be protected ─────────────────── */
const TABLE_MAP = {
user_profile : [
'username', 'firstname', 'lastname', 'email', 'phone_e164',
'zipcode', 'stripe_customer_id',
'interest_inventory_answers', 'riasec_scores',
'career_priorities', 'career_list'
],
financial_profiles : [
'current_salary','additional_income','monthly_expenses',
'monthly_debt_payments','retirement_savings','emergency_fund',
'retirement_contribution','emergency_contribution',
'extra_cash_emergency_pct','extra_cash_retirement_pct'
],
career_profiles : [
'planned_monthly_expenses','planned_monthly_debt_payments',
'planned_monthly_retirement_contribution','planned_monthly_emergency_contribution',
'planned_surplus_emergency_pct','planned_surplus_retirement_pct',
'planned_additional_income','career_goals','desired_retirement_income_monthly',
'career_name','start_date','retirement_start_date','scenario_title'
],
college_profiles : [
'selected_school','selected_program','annual_financial_aid',
'existing_college_debt','tuition','tuition_paid','loan_deferral_until_graduation',
'loan_term','interest_rate','extra_payment','expected_salary'
],
milestones : ['title','description','date','progress'],
tasks : ['title','description','due_date'],
reminders : ['phone_e164','message_body'],
milestone_impacts : ['amount','impact_type', 'direction'],
ai_risk_analysis : ['reasoning','risk_level'],
ai_generated_ksa : ['knowledge_json','abilities_json','skills_json'],
context_cache : ['ctx_text']
};
/* ── initialise KMS unwrap once ─────────────────────────────── */
async function ensureCryptoReady () { await initEncryption(); }
// choose cap per-process from env (server1/2/3 each have their own DB_POOL_SIZE)
const cap = Number(process.env.DB_POOL_SIZE || 5);
console.log(`[db] mysql pool connectionLimit=${cap}`);
/* ── mysql connection pool (uses env injected by docker) ────── */
export const pool = mysql.createPool({
host : process.env.DB_HOST,
port : process.env.DB_PORT,
user : process.env.DB_USER,
password : process.env.DB_PASSWORD,
database : process.env.DB_NAME,
waitForConnections : true,
connectionLimit : cap,
ssl : {
ca : process.env.DB_SSL_CA,
key : process.env.DB_SSL_KEY,
cert : process.env.DB_SSL_CERT,
servername: ''
}
});
/* ── tiny helpers to parse SQL (works for *your* queries) ───── */
function extractTables (sql) {
return [...new Set(
[...sql.matchAll(/\b(?:from|join|into|update)\s+`?([a-z0-9_]+)`?/ig)]
.map(m => m[1].toLowerCase())
)];
}
function extractColumn(sql, paramIndex) {
const s = sql.replace(/\s+/g, ' ').toLowerCase();
// INSERT INTO t (c1, c2, ...) VALUES (?, ?, ...)
const ins = s.match(/insert\s+into\s+[`\w]+\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i);
if (ins) {
const cols = ins[1].split(',').map(c => c.replace(/`/g, '').trim());
return cols[paramIndex] || null; // only VALUES params exist here
}
// UPDATE t SET c1 = ?, c2 = ? WHERE ...
const upd = s.match(/update\s+[`\w]+\s+set\s+(.+?)(?:\s+where|\s*$)/i);
if (upd) {
const setPart = upd[1];
// Build a list of columns in the same order as the '?'s in SET only
const pairs = setPart.split(',').map(p => p.trim());
const colsForQs = [];
for (const p of pairs) {
const col = p.split('=')[0].replace(/`/g, '').trim();
const qCnt = (p.match(/\?/g) || []).length;
for (let i = 0; i < qCnt; i++) colsForQs.push(col);
}
const setQCount = colsForQs.length;
if (paramIndex < setQCount) return colsForQs[paramIndex];
// params after SET (WHERE/LIMIT/etc.) → no mapping
return null;
}
// SELECT/DELETE/etc. → we dont map WHERE/LIMIT params
return null;
}
function decryptRow (row, tables) {
for (const t of tables) {
const encSet = new Set((TABLE_MAP[t] ?? []).map(c => c.toLowerCase()));
for (const k of Object.keys(row)) {
if (!encSet.has(k.toLowerCase())) continue;
const val = row[k];
if (val != null && isEncrypted(val)) row[k] = decrypt(val);
}
}
}
/* ─────────────────────────────────────────────────────────────
Replacement for pool.execute / pool.query
──────────────────────────────────────────────────────────── */
export async function exec(sql, params = []) {
await ensureCryptoReady();
const isWrite = WRITE_RE.test(sql);
const tables = extractTables(sql);
const encryptNeeded = (col) =>
tables.some(t => (TABLE_MAP[t] || []).some(c =>
c.toLowerCase() === String(col || '').toLowerCase()
));
const encParams = isWrite
? params.map((v, i) => {
const col = extractColumn(sql, i);
return (col && encryptNeeded(col) && v != null && !isEncrypted(v))
? encrypt(v)
: v;
})
: params; // SELECT path: never touch params
const [rows, fields] = await pool.execute(sql, encParams);
if (Array.isArray(rows)) for (const row of rows) decryptRow(row, tables);
return [rows, fields];
}
/* ── mysqllike façade so existing code keeps working ───────── */
export const query = exec;
export const getConnection = () => pool.getConnection();
export const end = () => pool.end();
export const poolRaw = pool; // escape hatch