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
This commit is contained in:
@@ -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:<password>@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
|
||||
|
||||
467
backend/db-schema.sql
Normal file
467
backend/db-schema.sql
Normal file
@@ -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;
|
||||
43
backend/db.js
Normal file
43
backend/db.js
Normal file
@@ -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;
|
||||
49
backend/setup-postgres.js
Normal file
49
backend/setup-postgres.js
Normal file
@@ -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();
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
"name": "cve-dashboard",
|
||||
"version": "1.0.0",
|
||||
"description": "STEAM Security Dashboard — vulnerability management for NTS-AEO",
|
||||
"author": "Jordan Ramos <jordan.ramos@spectrum.com>",
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user