From 845d843e71edc8771a80dc3a19ab155aaad03e50 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Tue, 5 May 2026 15:47:09 -0600 Subject: [PATCH] feat(postgres): infrastructure setup and schema creation (tasks 1-2) - Install pg (node-postgres) dependency - Create backend/db.js connection pool module (max 10, auto-reconnect) - Install Docker and spin up steam-postgres container on port 5433 - Create backend/db-schema.sql with complete Postgres DDL (24 tables) - Replace findings_json blob with ivanti_findings table (individual rows) - Merge notes/overrides into findings table columns - Add proper indexes: state, bu_ownership, severity, composite - Create backend/setup-postgres.js for idempotent schema initialization - Add DATABASE_URL to .env and .env.example - Update migration plan docs with Docker setup commands - Verify: schema executes cleanly, pool connects, 24 tables created --- backend/.env.example | 5 + backend/db-schema.sql | 467 +++++++++++++++++++++++++ backend/db.js | 43 +++ backend/setup-postgres.js | 49 +++ docs/guides/postgres-migration-plan.md | 29 ++ package.json | 7 +- 6 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 backend/db-schema.sql create mode 100644 backend/db.js create mode 100644 backend/setup-postgres.js diff --git a/backend/.env.example b/backend/.env.example index 5191710..bc4714a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -60,6 +60,11 @@ CARD_API_PASS= # Set to true if behind Charter's SSL inspection proxy CARD_SKIP_TLS=false +# PostgreSQL Database (Docker container steam-postgres) +# If set, the backend uses Postgres instead of SQLite. +# Format: postgresql://user:password@host:port/database +DATABASE_URL=postgresql://steam:@localhost:5433/cve_dashboard + # GitLab Feedback Integration (bug reports and feature requests from the dashboard) # PAT needs 'api' scope. Project ID is the numeric ID from GitLab project settings. GITLAB_URL=http://steam-gitlab.charterlab.com diff --git a/backend/db-schema.sql b/backend/db-schema.sql new file mode 100644 index 0000000..e1226f3 --- /dev/null +++ b/backend/db-schema.sql @@ -0,0 +1,467 @@ +-- ============================================================================= +-- CVE Dashboard — Complete PostgreSQL Schema (v1.0.0) +-- ============================================================================= +-- Translates the full SQLite schema (setup.js) to PostgreSQL 16. +-- Designed for idempotent execution: safe to run multiple times via psql or +-- pool.query() without errors or duplicate data. +-- +-- Usage: +-- psql -h localhost -p 5433 -U steam -d cve_dashboard -f backend/db-schema.sql +-- OR +-- const schema = fs.readFileSync('backend/db-schema.sql', 'utf8'); +-- await pool.query(schema); +-- ============================================================================= + +-- ============================================================================= +-- Core CVE tracking tables +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS cves ( + id SERIAL PRIMARY KEY, + cve_id VARCHAR(20) NOT NULL, + vendor VARCHAR(100) NOT NULL, + severity VARCHAR(20) NOT NULL, + description TEXT, + published_date DATE, + status VARCHAR(50) DEFAULT 'Open', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by INTEGER, + UNIQUE(cve_id, vendor) +); + +CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id); +CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor); +CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity); +CREATE INDEX IF NOT EXISTS idx_status ON cves(status); + +CREATE TABLE IF NOT EXISTS documents ( + id SERIAL PRIMARY KEY, + cve_id VARCHAR(20) NOT NULL, + vendor VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size VARCHAR(20), + mime_type VARCHAR(100), + uploaded_at TIMESTAMPTZ DEFAULT NOW(), + notes TEXT +); + +CREATE INDEX IF NOT EXISTS idx_doc_cve_id ON documents(cve_id); +CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor); +CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type); + +CREATE TABLE IF NOT EXISTS required_documents ( + id SERIAL PRIMARY KEY, + vendor VARCHAR(100) NOT NULL, + document_type VARCHAR(50) NOT NULL, + is_mandatory BOOLEAN DEFAULT TRUE, + description TEXT, + UNIQUE(vendor, document_type) +); + +-- ============================================================================= +-- Authentication and session management +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'editor', 'viewer')), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_login TIMESTAMPTZ, + user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only' + CHECK (user_group IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')), + bu_teams TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group); + +CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, + session_id VARCHAR(255) UNIQUE NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + +-- ============================================================================= +-- Audit logging +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS audit_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER, + username VARCHAR(50) NOT NULL, + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id VARCHAR(100), + details TEXT, + ip_address VARCHAR(45), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action); +CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type); +CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at); + +-- ============================================================================= +-- Jira integration +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS jira_tickets ( + id SERIAL PRIMARY KEY, + cve_id TEXT NOT NULL, + vendor TEXT NOT NULL, + ticket_key TEXT NOT NULL, + url TEXT, + summary TEXT, + status TEXT DEFAULT 'Open' CHECK (status IN ('Open', 'In Progress', 'Closed')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor); +CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status); + +-- ============================================================================= +-- Archer integration +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS archer_tickets ( + id SERIAL PRIMARY KEY, + exc_number TEXT NOT NULL UNIQUE, + archer_url TEXT, + status TEXT DEFAULT 'Draft' CHECK (status IN ('Draft', 'Open', 'Under Review', 'Accepted')), + cve_id TEXT NOT NULL, + vendor TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor); +CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status); +CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number); + +-- ============================================================================= +-- Knowledge base +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS knowledge_base ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) UNIQUE NOT NULL, + description TEXT, + category VARCHAR(100), + file_path VARCHAR(500), + file_name VARCHAR(255), + file_type VARCHAR(50), + file_size INTEGER, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by INTEGER REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug ON knowledge_base(slug); +CREATE INDEX IF NOT EXISTS idx_knowledge_base_category ON knowledge_base(category); +CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at ON knowledge_base(created_at DESC); + +-- ============================================================================= +-- Ivanti findings — individual rows (replaces findings_json blob) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS ivanti_findings ( + id TEXT PRIMARY KEY, + host_id INTEGER, + title TEXT NOT NULL DEFAULT '', + severity NUMERIC(4,2) NOT NULL DEFAULT 0, + vrr_group TEXT NOT NULL DEFAULT '', + host_name TEXT NOT NULL DEFAULT '', + ip_address TEXT NOT NULL DEFAULT '', + dns TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + sla_status TEXT NOT NULL DEFAULT '', + due_date DATE, + last_found_on DATE, + bu_ownership TEXT NOT NULL DEFAULT '', + cves TEXT[] DEFAULT '{}', + workflow_id TEXT, + workflow_state TEXT, + workflow_type TEXT, + state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')), + note TEXT NOT NULL DEFAULT '', + override_host_name TEXT, + override_dns TEXT, + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_findings_state ON ivanti_findings(state); +CREATE INDEX IF NOT EXISTS idx_findings_bu ON ivanti_findings(bu_ownership); +CREATE INDEX IF NOT EXISTS idx_findings_severity ON ivanti_findings(severity); +CREATE INDEX IF NOT EXISTS idx_findings_state_bu ON ivanti_findings(state, bu_ownership); + +-- ============================================================================= +-- Ivanti sync state (single-row pattern — replaces ivanti_findings_cache metadata) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS ivanti_sync_state ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + total INTEGER DEFAULT 0, + workflows_json TEXT DEFAULT '[]', + synced_at TIMESTAMPTZ, + sync_status TEXT DEFAULT 'never', + error_message TEXT +); + +-- ============================================================================= +-- Ivanti counts cache (single-row pattern for FP workflow counts) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS ivanti_counts_cache ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + open_count INTEGER DEFAULT 0, + closed_count INTEGER DEFAULT 0, + synced_at TIMESTAMPTZ, + fp_workflow_counts_json TEXT DEFAULT '{}', + fp_id_counts_json TEXT DEFAULT '{}' +); + +-- ============================================================================= +-- Ivanti counts history +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS ivanti_counts_history ( + id SERIAL PRIMARY KEY, + open_count INTEGER NOT NULL, + closed_count INTEGER NOT NULL, + recorded_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- Ivanti FP (False Positive) submissions +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS ivanti_fp_submissions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + username TEXT NOT NULL, + ivanti_workflow_batch_id INTEGER, + ivanti_generated_id TEXT, + ivanti_workflow_batch_uuid TEXT, + workflow_name TEXT NOT NULL, + reason TEXT NOT NULL, + description TEXT, + expiration_date TEXT NOT NULL, + scope_override TEXT NOT NULL DEFAULT 'Authorized', + finding_ids_json TEXT NOT NULL, + queue_item_ids_json TEXT NOT NULL, + attachment_count INTEGER DEFAULT 0, + attachment_results_json TEXT, + status TEXT NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'failed')), + lifecycle_status TEXT NOT NULL DEFAULT 'submitted' + CHECK (lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted')), + error_message TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NULL +); + +CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id); +CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id); + +CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history ( + id SERIAL PRIMARY KEY, + submission_id INTEGER NOT NULL REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL, + username TEXT NOT NULL, + change_type TEXT NOT NULL CHECK (change_type IN ( + 'created', 'fields_updated', 'findings_added', + 'attachments_added', 'status_changed' + )), + change_details_json TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id); + +-- ============================================================================= +-- Ivanti todo queue (FP, Archer, CARD, GRANITE workflows) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS ivanti_todo_queue ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + finding_id TEXT NOT NULL, + finding_title TEXT, + cves_json TEXT, + ip_address TEXT, + hostname TEXT, + vendor TEXT NOT NULL, + workflow_type TEXT NOT NULL CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'complete')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status); + +-- ============================================================================= +-- Ivanti archive detection and anomaly tracking +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS ivanti_finding_archives ( + id SERIAL PRIMARY KEY, + finding_id TEXT NOT NULL UNIQUE, + finding_title TEXT NOT NULL DEFAULT '', + host_name TEXT NOT NULL DEFAULT '', + ip_address TEXT NOT NULL DEFAULT '', + current_state TEXT NOT NULL CHECK (current_state IN ('ARCHIVED', 'RETURNED', 'CLOSED', 'CLOSED_GONE')), + last_severity NUMERIC(4,2) NOT NULL DEFAULT 0, + first_archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_transition_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_archive_finding_id ON ivanti_finding_archives(finding_id); +CREATE INDEX IF NOT EXISTS idx_archive_current_state ON ivanti_finding_archives(current_state); + +CREATE TABLE IF NOT EXISTS ivanti_archive_transitions ( + id SERIAL PRIMARY KEY, + archive_id INTEGER NOT NULL REFERENCES ivanti_finding_archives(id), + from_state TEXT NOT NULL, + to_state TEXT NOT NULL, + severity_at_transition NUMERIC(4,2) NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + transitioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_transition_archive_id ON ivanti_archive_transitions(archive_id); + +CREATE TABLE IF NOT EXISTS ivanti_sync_anomaly_log ( + id SERIAL PRIMARY KEY, + sync_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + open_count_delta INTEGER NOT NULL DEFAULT 0, + closed_count_delta INTEGER NOT NULL DEFAULT 0, + newly_archived_count INTEGER NOT NULL DEFAULT 0, + returned_count INTEGER NOT NULL DEFAULT 0, + classification_json TEXT NOT NULL DEFAULT '{}', + return_classification_json TEXT NOT NULL DEFAULT '{}', + is_significant BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_anomaly_sync_timestamp ON ivanti_sync_anomaly_log(sync_timestamp); + +CREATE TABLE IF NOT EXISTS ivanti_finding_bu_history ( + id SERIAL PRIMARY KEY, + finding_id TEXT NOT NULL, + finding_title TEXT NOT NULL DEFAULT '', + host_name TEXT NOT NULL DEFAULT '', + previous_bu TEXT NOT NULL, + new_bu TEXT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bu_history_finding_id ON ivanti_finding_bu_history(finding_id); +CREATE INDEX IF NOT EXISTS idx_bu_history_detected_at ON ivanti_finding_bu_history(detected_at); + +-- ============================================================================= +-- Atlas action plans cache +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS atlas_action_plans_cache ( + id SERIAL PRIMARY KEY, + host_id INTEGER NOT NULL UNIQUE, + has_action_plan BOOLEAN NOT NULL DEFAULT FALSE, + plan_count INTEGER NOT NULL DEFAULT 0, + plans_json TEXT NOT NULL DEFAULT '[]', + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id ON atlas_action_plans_cache(host_id); + +-- ============================================================================= +-- Compliance (NTS AEO) tracking +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS compliance_uploads ( + id SERIAL PRIMARY KEY, + filename TEXT NOT NULL, + report_date TEXT, + uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + uploaded_at TIMESTAMPTZ DEFAULT NOW(), + new_count INTEGER DEFAULT 0, + resolved_count INTEGER DEFAULT 0, + recurring_count INTEGER DEFAULT 0, + summary_json TEXT +); + +CREATE TABLE IF NOT EXISTS compliance_items ( + id SERIAL PRIMARY KEY, + upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE, + hostname TEXT NOT NULL, + ip_address TEXT, + device_type TEXT, + team TEXT, + metric_id TEXT NOT NULL, + metric_desc TEXT, + category TEXT, + extra_json TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'resolved')), + first_seen_upload_id INTEGER REFERENCES compliance_uploads(id) ON DELETE SET NULL, + resolved_upload_id INTEGER REFERENCES compliance_uploads(id) ON DELETE SET NULL, + seen_count INTEGER DEFAULT 1, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_compliance_items_upload ON compliance_items(upload_id); +CREATE INDEX IF NOT EXISTS idx_compliance_items_identity ON compliance_items(hostname, metric_id); +CREATE INDEX IF NOT EXISTS idx_compliance_items_team_status ON compliance_items(team, status); + +CREATE TABLE IF NOT EXISTS compliance_notes ( + id SERIAL PRIMARY KEY, + hostname TEXT NOT NULL, + metric_id TEXT NOT NULL, + note TEXT NOT NULL, + group_id TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_compliance_notes_identity ON compliance_notes(hostname, metric_id); +CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id); + +-- ============================================================================= +-- Seed data +-- ============================================================================= + +-- Required documents (idempotent via unique constraint on vendor + document_type) +INSERT INTO required_documents (vendor, document_type, is_mandatory, description) VALUES + ('Microsoft', 'advisory', TRUE, 'Official Microsoft Security Advisory'), + ('Microsoft', 'screenshot', FALSE, 'Proof of patch application'), + ('Cisco', 'advisory', TRUE, 'Cisco Security Advisory'), + ('Oracle', 'advisory', TRUE, 'Oracle Security Alert'), + ('VMware', 'advisory', TRUE, 'VMware Security Advisory'), + ('Adobe', 'advisory', TRUE, 'Adobe Security Bulletin') +ON CONFLICT (vendor, document_type) DO NOTHING; + +-- Ivanti sync state — ensure single row exists +INSERT INTO ivanti_sync_state (id, total, workflows_json, sync_status) +VALUES (1, 0, '[]', 'never') +ON CONFLICT (id) DO NOTHING; + +-- Ivanti counts cache — ensure single row exists +INSERT INTO ivanti_counts_cache (id, open_count, closed_count, fp_workflow_counts_json, fp_id_counts_json) +VALUES (1, 0, 0, '{}', '{}') +ON CONFLICT (id) DO NOTHING; diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..7b25f75 --- /dev/null +++ b/backend/db.js @@ -0,0 +1,43 @@ +// PostgreSQL Connection Pool +// All route files import this module instead of receiving a sqlite3 `db` parameter. +// Configured via DATABASE_URL environment variable. + +const { Pool } = require('pg'); + +if (!process.env.DATABASE_URL) { + console.error('[DB] FATAL: DATABASE_URL environment variable is not set.'); + console.error('[DB] Expected format: postgresql://user:password@host:port/database'); + process.exit(1); +} + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 10, // Maximum connections in pool + idleTimeoutMillis: 30000, // Close idle connections after 30s + connectionTimeoutMillis: 5000, // Fail if connection takes >5s +}); + +// Log unexpected pool errors (connection drops, etc.) +pool.on('error', (err) => { + console.error('[DB Pool] Unexpected error on idle client:', err.message); +}); + +// Track active connections and warn when approaching exhaustion +let _activeCount = 0; +pool.on('acquire', () => { + _activeCount++; + if (_activeCount >= 8) { + console.warn(`[DB Pool] WARNING: ${_activeCount}/10 connections active — approaching exhaustion`); + } +}); +pool.on('release', () => { _activeCount--; }); + +// Health check — verify connection on startup +pool.query('SELECT NOW()') + .then(() => console.log('[DB Pool] Connected to PostgreSQL')) + .catch((err) => { + console.error('[DB Pool] Failed to connect:', err.message); + console.error('[DB Pool] Check DATABASE_URL and ensure Postgres is running on port 5433'); + }); + +module.exports = pool; diff --git a/backend/setup-postgres.js b/backend/setup-postgres.js new file mode 100644 index 0000000..65fbf3f --- /dev/null +++ b/backend/setup-postgres.js @@ -0,0 +1,49 @@ +// Setup Script for CVE Dashboard — PostgreSQL +// Runs the db-schema.sql DDL against the Postgres instance configured in DATABASE_URL. +// Idempotent — safe to run multiple times. +// +// Usage: node backend/setup-postgres.js +// +// Requires DATABASE_URL in .env or environment. + +require('dotenv').config({ path: require('path').join(__dirname, '.env') }); + +const fs = require('fs'); +const path = require('path'); +const pool = require('./db'); + +const SCHEMA_FILE = path.join(__dirname, 'db-schema.sql'); + +async function main() { + console.log('🚀 CVE Dashboard — PostgreSQL Schema Setup\n'); + console.log('════════════════════════════════════════\n'); + + try { + // Verify connection + const { rows } = await pool.query('SELECT version()'); + console.log(`✓ Connected to: ${rows[0].version.split(',')[0]}`); + console.log(` Database URL: ${process.env.DATABASE_URL.replace(/:[^:@]+@/, ':***@')}\n`); + + // Read and execute schema + const schema = fs.readFileSync(SCHEMA_FILE, 'utf8'); + await pool.query(schema); + console.log('✓ Schema created/verified (all tables and indexes)\n'); + + // Verify table count + const { rows: tables } = await pool.query( + "SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = 'public'" + ); + console.log(`✓ ${tables[0].count} tables in database\n`); + + console.log('╔════════════════════════════════════════════════════════╗'); + console.log('║ POSTGRESQL SCHEMA SETUP COMPLETE ║'); + console.log('╚════════════════════════════════════════════════════════╝\n'); + } catch (err) { + console.error('❌ Setup failed:', err.message); + process.exit(1); + } finally { + await pool.end(); + } +} + +main(); diff --git a/docs/guides/postgres-migration-plan.md b/docs/guides/postgres-migration-plan.md index d7895d4..6051c89 100644 --- a/docs/guides/postgres-migration-plan.md +++ b/docs/guides/postgres-migration-plan.md @@ -258,3 +258,32 @@ systemctl start cve-backend - **No JSON parsing**: Findings are rows, not a blob - **Concurrent access**: Multiple users can read while sync writes - **Future-proof**: Easy to add full-text search, materialized views, partitioning + +## Docker Container Setup + +Run this once to create the Postgres container: + +```bash +docker run -d --name steam-postgres \ + --restart unless-stopped \ + -e POSTGRES_DB=cve_dashboard \ + -e POSTGRES_USER=steam \ + -e POSTGRES_PASSWORD=sV4xmC9xAUCFop0ypxMVS056QgPqGrX \ + -p 5433:5432 \ + -v steam-pgdata:/var/lib/postgresql/data \ + postgres:16-alpine +``` + +Verify it's running: +```bash +docker ps | grep steam-postgres +psql -h localhost -p 5433 -U steam -d cve_dashboard -c "SELECT 1;" +``` + +Management commands: +```bash +docker stop steam-postgres # Stop +docker start steam-postgres # Start +docker logs steam-postgres # View logs +docker exec -it steam-postgres psql -U steam -d cve_dashboard # Shell access +``` diff --git a/package.json b/package.json index d4ec868..91355f5 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,13 @@ "name": "cve-dashboard", "version": "1.0.0", "description": "STEAM Security Dashboard — vulnerability management for NTS-AEO", - "author": "Jordan Ramos ", - "license": "UNLICENSED", + "author": "", + "license": "ISC", "private": true, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], - "author": "", - "license": "ISC", "dependencies": { "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", @@ -19,6 +17,7 @@ "express": "^5.2.1", "express-rate-limit": "^7.5.0", "multer": "^2.0.2", + "pg": "^8.20.0", "sqlite3": "^5.1.7" }, "devDependencies": {