diff --git a/backend/migrate_jira_tickets.js b/backend/migrate_jira_tickets.js new file mode 100644 index 0000000..dc66657 --- /dev/null +++ b/backend/migrate_jira_tickets.js @@ -0,0 +1,39 @@ +// Migration: Add jira_tickets table +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, 'cve_database.db'); +const db = new sqlite3.Database(dbPath); + +console.log('Starting JIRA tickets migration...'); + +db.serialize(() => { + // Create jira_tickets table + db.run(` + CREATE TABLE IF NOT EXISTS jira_tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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 DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE + ) + `, (err) => { + if (err) console.error('Error creating table:', err); + else console.log('✓ jira_tickets table created'); + }); + + // Create indexes + db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor)'); + db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status)'); + + console.log('✓ Indexes created'); +}); + +db.close(() => { + console.log('Migration complete!'); +}); diff --git a/backend/server.js b/backend/server.js index aeae480..f740084 100644 --- a/backend/server.js +++ b/backend/server.js @@ -78,6 +78,7 @@ function isValidCveId(cveId) { const VALID_SEVERITIES = ['Critical', 'High', 'Medium', 'Low']; const VALID_STATUSES = ['Open', 'Addressed', 'In Progress', 'Resolved']; const VALID_DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other']; +const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed']; // Validate vendor name - printable chars, reasonable length function isValidVendor(vendor) { @@ -857,7 +858,7 @@ app.get('/api/vendors', requireAuth(db), (req, res) => { // Get statistics (authenticated users) app.get('/api/stats', requireAuth(db), (req, res) => { const query = ` - SELECT + SELECT COUNT(DISTINCT c.id) as total_cves, COUNT(DISTINCT CASE WHEN c.severity = 'Critical' THEN c.id END) as critical_count, COUNT(DISTINCT CASE WHEN c.status = 'Addressed' THEN c.id END) as addressed_count, @@ -867,7 +868,7 @@ app.get('/api/stats', requireAuth(db), (req, res) => { LEFT JOIN documents d ON c.cve_id = d.cve_id LEFT JOIN cve_document_status cd ON c.cve_id = cd.cve_id `; - + db.get(query, [], (err, row) => { if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); @@ -876,6 +877,192 @@ app.get('/api/stats', requireAuth(db), (req, res) => { }); }); +// ========== JIRA TICKET ENDPOINTS ========== + +// Get all JIRA tickets (with optional filters) +app.get('/api/jira-tickets', requireAuth(db), (req, res) => { + const { cve_id, vendor, status } = req.query; + + let query = 'SELECT * FROM jira_tickets WHERE 1=1'; + const params = []; + + if (cve_id) { + query += ' AND cve_id = ?'; + params.push(cve_id); + } + if (vendor) { + query += ' AND vendor = ?'; + params.push(vendor); + } + if (status) { + query += ' AND status = ?'; + params.push(status); + } + + query += ' ORDER BY created_at DESC'; + + db.all(query, params, (err, rows) => { + if (err) { + console.error('Error fetching JIRA tickets:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + res.json(rows); + }); +}); + +// Create JIRA ticket +app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { + const { cve_id, vendor, ticket_key, url, summary, status } = req.body; + + // Validation + if (!cve_id || !isValidCveId(cve_id)) { + return res.status(400).json({ error: 'Valid CVE ID is required.' }); + } + if (!vendor || !isValidVendor(vendor)) { + return res.status(400).json({ error: 'Valid vendor is required.' }); + } + if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) { + return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' }); + } + if (url && (typeof url !== 'string' || url.length > 500)) { + return res.status(400).json({ error: 'URL must be under 500 characters.' }); + } + if (summary && (typeof summary !== 'string' || summary.length > 500)) { + return res.status(400).json({ error: 'Summary must be under 500 characters.' }); + } + if (status && !VALID_TICKET_STATUSES.includes(status)) { + return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` }); + } + + const ticketStatus = status || 'Open'; + + const query = ` + INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status) + VALUES (?, ?, ?, ?, ?, ?) + `; + + db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus], function(err) { + if (err) { + console.error('Error creating JIRA ticket:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'jira_ticket_create', + entityType: 'jira_ticket', + entityId: this.lastID.toString(), + details: { cve_id, vendor, ticket_key, status: ticketStatus }, + ipAddress: req.ip + }); + + res.status(201).json({ + id: this.lastID, + message: 'JIRA ticket created successfully' + }); + }); +}); + +// Update JIRA ticket +app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { + const { id } = req.params; + const { ticket_key, url, summary, status } = req.body; + + // Validation + if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) { + return res.status(400).json({ error: 'Ticket key must be under 50 chars.' }); + } + if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) { + return res.status(400).json({ error: 'URL must be under 500 characters.' }); + } + if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) { + return res.status(400).json({ error: 'Summary must be under 500 characters.' }); + } + if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) { + return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` }); + } + + // Build dynamic update + const fields = []; + const values = []; + + if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); } + if (url !== undefined) { fields.push('url = ?'); values.push(url); } + if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); } + if (status !== undefined) { fields.push('status = ?'); values.push(status); } + + if (fields.length === 0) { + return res.status(400).json({ error: 'No fields to update.' }); + } + + fields.push('updated_at = CURRENT_TIMESTAMP'); + values.push(id); + + db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => { + if (err) { + console.error(err); + return res.status(500).json({ error: 'Internal server error.' }); + } + if (!existing) { + return res.status(404).json({ error: 'JIRA ticket not found.' }); + } + + db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) { + if (updateErr) { + console.error('Error updating JIRA ticket:', updateErr); + return res.status(500).json({ error: 'Internal server error.' }); + } + + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'jira_ticket_update', + entityType: 'jira_ticket', + entityId: id, + details: { before: existing, changes: req.body }, + ipAddress: req.ip + }); + + res.json({ message: 'JIRA ticket updated successfully', changes: this.changes }); + }); + }); +}); + +// Delete JIRA ticket +app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { + const { id } = req.params; + + db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => { + if (err) { + console.error(err); + return res.status(500).json({ error: 'Internal server error.' }); + } + if (!ticket) { + return res.status(404).json({ error: 'JIRA ticket not found.' }); + } + + db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) { + if (deleteErr) { + console.error('Error deleting JIRA ticket:', deleteErr); + return res.status(500).json({ error: 'Internal server error.' }); + } + + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'jira_ticket_delete', + entityType: 'jira_ticket', + entityId: id, + details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor }, + ipAddress: req.ip + }); + + res.json({ message: 'JIRA ticket deleted successfully' }); + }); + }); +}); + // Start server app.listen(PORT, () => { console.log(`CVE API server running on http://${API_HOST}:${PORT}`); diff --git a/frontend/src/App.js b/frontend/src/App.js index 5cf9b9b..b2d0feb 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -51,6 +51,15 @@ export default function App() { const [editNvdError, setEditNvdError] = useState(null); const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false); const [expandedCVEs, setExpandedCVEs] = useState({}); + const [jiraTickets, setJiraTickets] = useState([]); + const [showAddTicket, setShowAddTicket] = useState(false); + const [showEditTicket, setShowEditTicket] = useState(false); + const [editingTicket, setEditingTicket] = useState(null); + const [ticketForm, setTicketForm] = useState({ + cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' + }); + // For adding ticket from within a CVE card + const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor } const toggleCVEExpand = (cveId) => { setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] })); @@ -125,6 +134,19 @@ export default function App() { } }; + const fetchJiraTickets = async () => { + try { + const response = await fetch(`${API_BASE}/jira-tickets`, { + credentials: 'include' + }); + if (!response.ok) throw new Error('Failed to fetch JIRA tickets'); + const data = await response.json(); + setJiraTickets(data); + } catch (err) { + console.error('Error fetching JIRA tickets:', err); + } + }; + const fetchDocuments = async (cveId, vendor) => { const key = `${cveId}-${vendor}`; if (cveDocuments[key]) return; @@ -391,14 +413,21 @@ export default function App() { } try { - const response = await fetch(`${API_BASE}/cves/${cve.id}`, { + const url = `${API_BASE}/cves/${cve.id}`; + console.log('DELETE request to:', url); + const response = await fetch(url, { method: 'DELETE', credentials: 'include' }); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to delete CVE entry'); + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const data = await response.json(); + throw new Error(data.error || 'Failed to delete CVE entry'); + } else { + throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`); + } } alert(`Deleted ${cve.vendor} entry for ${cve.cve_id}`); @@ -415,14 +444,21 @@ export default function App() { } try { - const response = await fetch(`${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`, { + const url = `${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`; + console.log('DELETE request to:', url); + const response = await fetch(url, { method: 'DELETE', credentials: 'include' }); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to delete CVE'); + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const data = await response.json(); + throw new Error(data.error || 'Failed to delete CVE'); + } else { + throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`); + } } alert(`Deleted all entries for ${cveId}`); @@ -433,11 +469,97 @@ export default function App() { } }; + const handleAddTicket = async (e) => { + e.preventDefault(); + try { + const response = await fetch(`${API_BASE}/jira-tickets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(ticketForm) + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to create ticket'); + } + alert('JIRA ticket added successfully!'); + setShowAddTicket(false); + setAddTicketContext(null); + setTicketForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' }); + fetchJiraTickets(); + } catch (err) { + alert(`Error: ${err.message}`); + } + }; + + const handleEditTicket = (ticket) => { + setEditingTicket(ticket); + setTicketForm({ + cve_id: ticket.cve_id, + vendor: ticket.vendor, + ticket_key: ticket.ticket_key, + url: ticket.url || '', + summary: ticket.summary || '', + status: ticket.status + }); + setShowEditTicket(true); + }; + + const handleUpdateTicket = async (e) => { + e.preventDefault(); + try { + const response = await fetch(`${API_BASE}/jira-tickets/${editingTicket.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + ticket_key: ticketForm.ticket_key, + url: ticketForm.url, + summary: ticketForm.summary, + status: ticketForm.status + }) + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update ticket'); + } + alert('JIRA ticket updated!'); + setShowEditTicket(false); + setEditingTicket(null); + setTicketForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' }); + fetchJiraTickets(); + } catch (err) { + alert(`Error: ${err.message}`); + } + }; + + const handleDeleteTicket = async (ticket) => { + if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return; + try { + const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, { + method: 'DELETE', + credentials: 'include' + }); + if (!response.ok) throw new Error('Failed to delete ticket'); + alert('Ticket deleted'); + fetchJiraTickets(); + } catch (err) { + alert(`Error: ${err.message}`); + } + }; + + const openAddTicketForCVE = (cve_id, vendor) => { + setAddTicketContext({ cve_id, vendor }); + setTicketForm({ cve_id, vendor, ticket_key: '', url: '', summary: '', status: 'Open' }); + setShowAddTicket(true); + }; + // Fetch CVEs from API when authenticated useEffect(() => { if (isAuthenticated) { fetchCVEs(); fetchVendors(); + fetchJiraTickets(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated]); @@ -801,6 +923,175 @@ export default function App() { )} + {/* Add JIRA Ticket Modal */} + {showAddTicket && ( +
No JIRA tickets linked
+ )} +