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
|
# Set to true if behind Charter's SSL inspection proxy
|
||||||
CARD_SKIP_TLS=false
|
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)
|
# 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.
|
# PAT needs 'api' scope. Project ID is the numeric ID from GitLab project settings.
|
||||||
GITLAB_URL=http://steam-gitlab.charterlab.com
|
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
|
- **No JSON parsing**: Findings are rows, not a blob
|
||||||
- **Concurrent access**: Multiple users can read while sync writes
|
- **Concurrent access**: Multiple users can read while sync writes
|
||||||
- **Future-proof**: Easy to add full-text search, materialized views, partitioning
|
- **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",
|
"name": "cve-dashboard",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "STEAM Security Dashboard — vulnerability management for NTS-AEO",
|
"description": "STEAM Security Dashboard — vulnerability management for NTS-AEO",
|
||||||
"author": "Jordan Ramos <jordan.ramos@spectrum.com>",
|
"author": "",
|
||||||
"license": "UNLICENSED",
|
"license": "ISC",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
@@ -19,6 +17,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user